import { expect, test, type Page, type Route } from "@playwright/test"; import type { ActualTransferExecutionRequest, ActualTransferExecutionResponse, CalibrationTransferReadModelResponse, PredictionLockRequest, PredictionRevisionRequest, TeacherCorrectionRequest, } from "../src/pages/session-review/calibrationTransferApi"; import { parsePracticeLaunchIntent } from "../src/lib/practiceLaunchIntent"; import { filledReviewResponse } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; const SESSION_ID = "51000000-0000-0000-0000-000000000110"; const PRACTICE_SESSION_ID = "51000000-0000-0000-0000-000000000111"; const LEARNER_ID = "51000000-0000-0000-0000-000000000001"; const HISTORY_ID = "51000000-0000-0000-0000-000000000210"; const REVISION_ID = "51000000-0000-0000-0000-000000000211"; const ASSESSMENT_ID = "51000000-0000-0000-0000-000000000410"; const TRANSFER_SUITE_ID = "51000000-0000-0000-0000-000000000510"; const TRANSFER_ASSESSMENT_ID = "51000000-0000-0000-0000-000000000520"; const DRIFT_ID = "51000000-0000-0000-0000-000000000530"; function routeAuth(page: Page, role: "learner" | "teacher") { return page.route("**/auth/me", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: role === "learner" ? LEARNER_ID : "51000000-0000-0000-0000-000000000002", email: `${role}@hs.ac.kr`, role, display_name: role === "learner" ? "E2E Learner" : "E2E Teacher", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: ["g5-cohort"], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: role === "learner" ? "E2E Learner" : "E2E Teacher", self_introduction: "", avatar_url: "", }), }), ); } function routeUnmockedApi(page: Page) { return page.route("**/api/**", (route) => route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "not part of the focused G5 fixture" }), }), ); } async function routeReviewShell(page: Page, reviewSessionId = SESSION_ID) { await page.route(`**/api/sessions/${reviewSessionId}/review`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(filledReviewResponse(reviewSessionId)), }), ); await page.route("**/api/users/me/prepost-measures", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }), ); await page.route(`**/api/sessions/${reviewSessionId}/alliance-pulses`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }), ); for (const suffix of ["outcome-trajectory", "ruptures"]) { await page.route(`**/api/sessions/${reviewSessionId}/${suffix}`, (route) => route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "fixture owns only the G5 surface" }), }), ); } await page.route("**/practice/learners/**", (route) => route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "fixture owns only the G5 surface" }), }), ); } function routeLearnerReadModel( page: Page, expectedSessionIds: readonly string[], handler: (route: Route) => void | Promise, ) { return page.route( (url) => url.pathname === "/api/calibration/learners/me" && url.searchParams.has("session_id"), async (route) => { const sessionId = new URL(route.request().url()).searchParams.get("session_id"); expect(expectedSessionIds).toContain(sessionId); await handler(route); }, ); } function initialReadModel( requestedView: "learner" | "supervisor" = "learner", ): CalibrationTransferReadModelResponse { return { learner_id: LEARNER_ID, requested_view: requestedView, clinical_claim_allowed: false, prediction_histories: [ { history_id: HISTORY_ID, session_id: SESSION_ID, competency_id: "competency.empathic_reflection", practice_block_id: "oas-g5-block-session-review", scenario_variant_id: "unseen-school-refusal", phrase_family_id: "learner-reflection-a", created_at: "2026-08-06T01:00:00Z", revisions: [ { prediction_revision_id: REVISION_ID, submission_id: "51000000-0000-0000-0000-000000000212", history_id: HISTORY_ID, revision_no: 1, supersedes_prediction_revision_id: null, predicted_success_probability: 0.6, confidence: 0.55, recorded_sequence: 1, revision_reason: "반영 뒤 내담자 반응이 조금 열렸기 때문", source_kind: "learner_reported", perspective: "learner_self_report", instrument_id: "vignette.calibration-self-prediction", instrument_version: "1.0.0", evidence_turn_ids: [], created_at: "2026-08-06T01:00:00Z", }, ], lock: null, external_observation: null, }, ], calibration_assessments: [], transfer_suites: [], teacher_reviews: [], actual_executions: [], actual_transfer_assessments: [], }; } function actualTransferResponse( idempotentReplay: boolean, ): ActualTransferExecutionResponse { return { execution: { execution_event_id: "51000000-0000-0000-0000-000000000611", original_transfer_trial_record_id: "51000000-0000-0000-0000-000000000515", practice_session_id: PRACTICE_SESSION_ID, competency_id: "competency.empathic_reflection", scenario_variant_id: "family-conflict-confrontational", scenario_novelty: "unseen_transfer", variation: { context_variant: "family-conflict", relationship_style: "confrontational", difficulty_level: 5, expression_variant: "direct-anger", synthetic_subgroup: "synthetic-family-b", scenario_family_id: "family-conflict", phrase_family_id: "novel-reflection-c", }, training_phrase_collision: false, status: "passed", uncertainty: 0.18, evidence_turn_ids: ["51000000-0000-0000-0000-000000000612"], normalized_evaluator_labels: { technique_codes: ["reflection.feeling"], client_state_codes: ["engaged"], appropriateness: ["pos"], intent_deviation_dimensions: [], evaluator_error_count: 0, }, counterevidence: [], model_run_id: "51000000-0000-0000-0000-000000000613", created_at: "2026-08-06T02:00:00Z", }, assessment: { evidence_source: "actual_practice_execution", competency_id: "competency.empathic_reflection", execution_count: 1, independent_execution_count: 1, observed_execution_count: 1, success_rate: 1, success_interval: { method: "wilson_95", lower: 0.21, upper: 1 }, coverage: { contexts: 1, relationship_styles: 1, difficulty_levels: 1, expression_variants: 1, scenario_families: 1, phrase_families: 1, }, phrase_family_collision_count: 0, eligible: false, actual_transfer_status: "insufficient_evidence", blockers: [ "actual_independent_phrase_families_below_minimum:1/6", "actual_observed_executions_below_minimum:1/6", "actual_transfer_coverage_missing:contexts", ], source_execution_event_ids: [ "51000000-0000-0000-0000-000000000611", ], evidence_turn_ids: ["51000000-0000-0000-0000-000000000612"], }, idempotent_replay: idempotentReplay, }; } function revealAssessment(readModel: CalibrationTransferReadModelResponse) { const history = readModel.prediction_histories[0]; const latest = history.revisions.at(-1)!; history.lock = { lock_id: "51000000-0000-0000-0000-000000000310", submission_id: "51000000-0000-0000-0000-000000000311", history_id: HISTORY_ID, prediction_revision_id: latest.prediction_revision_id, locked_sequence: latest.recorded_sequence + 1, created_at: "2026-08-06T01:02:00Z", }; history.external_observation = { observation_id: "51000000-0000-0000-0000-000000000320", submission_id: "51000000-0000-0000-0000-000000000321", history_id: HISTORY_ID, status: "failed", source_kind: "model_inferred", perspective: "independent_observer", model_run_id: "51000000-0000-0000-0000-000000000322", instrument_id: "vignette.performance-observer", instrument_version: "1.0.0", uncertainty: 0.18, evidence_turn_ids: ["51000000-0000-0000-0000-000000000323"], counterevidence: ["reflection_did_not_change_response"], revealed_sequence: latest.recorded_sequence + 2, created_at: "2026-08-06T01:03:00Z", }; readModel.calibration_assessments = [ { assessment_snapshot_id: ASSESSMENT_ID, submission_id: "51000000-0000-0000-0000-000000000411", session_id: SESSION_ID, competency_id: "competency.empathic_reflection", snapshot_no: 1, supersedes_assessment_snapshot_id: null, source_observation_ids: [history.external_observation.observation_id], assessment_payload: { competency_id: "competency.empathic_reflection", pair_count: 4, mean_absolute_error: 0.24, mean_signed_error: 0.21, error_interval: { method: "normal_95_bounded", lower: 0.14, upper: 0.34, }, bias: "overconfident", baseline_error: 0.3, recent_error: 0.24, improvement: "improved", pairs: [ { practice_block_id: "oas-g5-block-001", prediction_id: "oas-g5-prediction-001", observation_id: "oas-g5-observation-001", predicted_success_probability: 0.3, observed_success: false, signed_error: 0.3, absolute_error: 0.3, confidence: 0.8, evidence_refs: ["51000000-0000-0000-0000-000000000421"], }, { practice_block_id: "oas-g5-block-002", prediction_id: "oas-g5-prediction-002", observation_id: "oas-g5-observation-002", predicted_success_probability: 0.25, observed_success: false, signed_error: 0.25, absolute_error: 0.25, confidence: 0.6, evidence_refs: ["51000000-0000-0000-0000-000000000422"], }, { practice_block_id: "oas-g5-block-003", prediction_id: "oas-g5-prediction-003", observation_id: "oas-g5-observation-003", predicted_success_probability: 0.35, observed_success: false, signed_error: 0.35, absolute_error: 0.35, confidence: 0.7, evidence_refs: ["51000000-0000-0000-0000-000000000423"], }, { practice_block_id: "oas-g5-block-004", prediction_id: "oas-g5-prediction-004", observation_id: "oas-g5-observation-004", predicted_success_probability: 0.94, observed_success: true, signed_error: -0.06, absolute_error: 0.06, confidence: 0.75, evidence_refs: ["51000000-0000-0000-0000-000000000424"], }, ], excluded_block_ids: [], counterevidence: ["recent_prediction_still_above_observation"], }, model_run_id: "51000000-0000-0000-0000-000000000412", instrument_id: "vignette.calibration-assessment", instrument_version: "1.0.0", evidence_turn_ids: ["51000000-0000-0000-0000-000000000323"], created_at: "2026-08-06T01:04:00Z", prescription_id: "51000000-0000-0000-0000-000000000413", prescription_payload: { competency_id: "competency.empathic_reflection", bias: "overconfident", practice_mode: "counterevidence_forecast", instruction_ko: "다음 예측 전에 실패할 수 있는 장면 근거 두 가지를 먼저 적고 성공 가능성을 다시 잠가.", completion_evidence: [ "two_counterevidence_refs", "revised_probability_range_before_reveal", ], }, }, ]; readModel.transfer_suites = [ { transfer_suite_record_id: TRANSFER_SUITE_ID, submission_id: "51000000-0000-0000-0000-000000000511", suite_key: "oas-g5-suite-school-context", session_id: SESSION_ID, training_phrase_family_ids: ["memorized-reflection-a"], model_run_id: "51000000-0000-0000-0000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", data_classification: "synthetic_educational", clinical_claim_allowed: false, created_at: "2026-08-06T01:05:00Z", trials: [ { transfer_trial_record_id: "51000000-0000-0000-0000-000000000513", transfer_suite_record_id: TRANSFER_SUITE_ID, trial_key: "oas-g5-transfer-001", competency_id: "competency.empathic_reflection", scenario_variant_id: "school-refusal-withdrawn", scenario_novelty: "unseen_transfer", context_variant: "school-refusal", relationship_style: "withdrawn", difficulty_level: 4, expression_variant: "indirect-shame", synthetic_subgroup: "synthetic-school-a", scenario_family_id: "school-refusal", phrase_family_id: "memorized-reflection-a", status: "passed", uncertainty: 0.2, evidence_turn_ids: ["51000000-0000-0000-0000-000000000514"], counterevidence: [], model_run_id: "51000000-0000-0000-0000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", created_at: "2026-08-06T01:05:00Z", }, { transfer_trial_record_id: "51000000-0000-0000-0000-000000000515", transfer_suite_record_id: TRANSFER_SUITE_ID, trial_key: "oas-g5-transfer-002", competency_id: "competency.empathic_reflection", scenario_variant_id: "family-conflict-confrontational", scenario_novelty: "unseen_transfer", context_variant: "family-conflict", relationship_style: "confrontational", difficulty_level: 5, expression_variant: "direct-anger", synthetic_subgroup: "synthetic-family-b", scenario_family_id: "family-conflict", phrase_family_id: "novel-reflection-b", status: "failed", uncertainty: 0.22, evidence_turn_ids: ["51000000-0000-0000-0000-000000000516"], counterevidence: ["client_rejected_reflection"], model_run_id: "51000000-0000-0000-0000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", created_at: "2026-08-06T01:06:00Z", }, ], assessments: [ { transfer_assessment_id: TRANSFER_ASSESSMENT_ID, transfer_suite_record_id: TRANSFER_SUITE_ID, competency_id: "competency.empathic_reflection", source_trial_ids: [ "51000000-0000-0000-0000-000000000513", "51000000-0000-0000-0000-000000000515", ], assessment_payload: { competency_id: "competency.empathic_reflection", trial_count: 2, observed_trial_count: 2, success_rate: 0.5, success_interval: { method: "wilson_95", lower: 0.09, upper: 0.91 }, coverage: { contexts: 2, relationship_styles: 2, difficulty_levels: 2, expression_variants: 2, scenario_families: 2, synthetic_subgroups: 2, }, eligible: false, transfer_verified: false, blockers: [ "observed_trials_below_minimum:2/6", "memorized_training_phrase_reused", ], evidence_refs: [ "51000000-0000-0000-0000-000000000514", "51000000-0000-0000-0000-000000000516", ], counterevidence: ["client_rejected_reflection"], }, evidence_turn_ids: [ "51000000-0000-0000-0000-000000000514", "51000000-0000-0000-0000-000000000516", ], model_run_id: "51000000-0000-0000-0000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", created_at: "2026-08-06T01:07:00Z", }, ], drift_reports: [ { drift_report_id: DRIFT_ID, transfer_suite_record_id: TRANSFER_SUITE_ID, competency_id: "competency.empathic_reflection", source_trial_ids: [ "51000000-0000-0000-0000-000000000513", "51000000-0000-0000-0000-000000000515", ], report_payload: { competency_id: "competency.empathic_reflection", status: "insufficient_evidence", max_rate_gap: null, compared_subgroups: [], subgroup_results: [], threshold: 0.2, notice_ko: "교육용 합성 subgroup별 관측이 부족해 실제 집단 차이를 주장하지 않는다.", }, model_run_id: "51000000-0000-0000-0000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", data_classification: "synthetic_educational", clinical_claim_allowed: false, created_at: "2026-08-06T01:08:00Z", }, ], }, ]; } async function fulfillReadModel(route: Route, readModel: CalibrationTransferReadModelResponse) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(readModel), }); } async function expectAtMainScrollStart( page: Page, selector: string, label: string, ) { const main = page.locator(".vg-main"); await main.evaluate((element) => { element.scrollTop = 0; }); const result = await page.locator(selector).evaluate((element) => { const mainElement = document.querySelector(".vg-main"); if (!mainElement) throw new Error(".vg-main not found"); const mainRect = mainElement.getBoundingClientRect(); const rect = element.getBoundingClientRect(); return { scrollTop: mainElement.scrollTop, top: rect.top, bottom: rect.bottom, mainTop: mainRect.top, mainBottom: mainRect.bottom, height: rect.height, }; }); expect(result.scrollTop, `${label} main scroll start`).toBe(0); expect(result.top, `${label} top`).toBeGreaterThanOrEqual(result.mainTop); expect(result.bottom, `${label} bottom`).toBeLessThanOrEqual(result.mainBottom); expect(result.height, `${label} touch target`).toBeGreaterThanOrEqual(44); } test.describe("G5 calibration mirror and unseen transfer", () => { test("learner revises then explicitly locks before role-safe reveal", async ({ page }) => { const readModel = initialReadModel(); let revisionRequests = 0; let lockRequests = 0; await routeUnmockedApi(page); await routeAuth(page, "learner"); await routeReviewShell(page); await routeLearnerReadModel(page, [SESSION_ID], (route) => fulfillReadModel(route, readModel), ); await page.route("**/api/calibration/predictions/revisions", async (route) => { revisionRequests += 1; const body = route.request().postDataJSON() as PredictionRevisionRequest; expect(body.revision_no).toBe(2); expect(body.supersedes_prediction_revision_id).toBe(REVISION_ID); expect(body).not.toHaveProperty("total_score"); readModel.prediction_histories[0].revisions.push({ ...body, source_kind: "learner_reported", perspective: "learner_self_report", created_at: "2026-08-06T01:01:00Z", }); await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ submission_id: body.submission_id, history_id: body.history_id, prediction_revision_id: body.prediction_revision_id, revision_no: body.revision_no, idempotent_replay: false, }), }); }); await page.route(`**/api/calibration/predictions/${HISTORY_ID}/lock`, async (route) => { lockRequests += 1; const body = route.request().postDataJSON() as PredictionLockRequest; expect(body.prediction_revision_id).toBe( readModel.prediction_histories[0].revisions.at(-1)?.prediction_revision_id, ); revealAssessment(readModel); readModel.prediction_histories[0].lock = { ...readModel.prediction_histories[0].lock!, submission_id: body.submission_id, lock_id: body.lock_id, locked_sequence: body.locked_sequence, }; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ submission_id: body.submission_id, history_id: HISTORY_ID, lock_id: body.lock_id, idempotent_replay: false, }), }); }); await page.goto(`/learn/session/${SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".ct-card"); await expect(card.getByRole("heading", { name: "먼저 예측하고, 잠근 뒤, 근거로 교정하기" })).toBeVisible(); await expect(card.getByText("아직 공개하지 않음")).toBeVisible(); const probability = card.getByRole("slider", { name: /성공 가능성/ }); await probability.focus(); await page.keyboard.press("ArrowRight"); await expect(probability).toHaveValue("65"); await card.getByLabel("그렇게 예상한 이유").fill("새 장면에서도 반영의 속도를 유지할 수 있을 것 같아"); await card.getByRole("button", { name: "예측 수정 기록" }).click(); await expect(card.getByText("revision 2", { exact: false }).first()).toBeVisible(); const lockButton = card.getByRole("button", { name: "이 예측 잠그기" }); await expect(lockButton).toBeDisabled(); await card.getByLabel("잠근 뒤 수정할 수 없음을 확인했어").check(); await expect(lockButton).toBeEnabled(); await lockButton.click(); await expect(card.getByText("잠근 예측은 수정할 수 없어", { exact: false })).toBeVisible(); await expect(card.getByText("독립 관찰", { exact: true })).toBeVisible(); await expect(card.getByText("과신 경향")).toBeVisible(); await expect(card.getByText("평균 예측 오차")).toBeVisible(); await expect(card.getByText("14%–34%")).toBeVisible(); await expect(card.getByText("다음에 직접 할 일")).toBeVisible(); await expect(card.getByText("암기 문장 재사용 감지 · 전이 인정 차단")).toBeVisible(); await expect(card.getByText("상황 맥락", { exact: true })).toBeVisible(); await expect(card.getByText("관계 스타일", { exact: true })).toBeVisible(); await expect( card.locator(".ct-coverage-grid").getByText("난도", { exact: true }), ).toBeVisible(); await expect(card.getByText("표현군", { exact: true })).toBeVisible(); await expect(card.getByText("실제 인구집단·진단·임상 결과에 대한 주장이 아니야", { exact: false })).toBeVisible(); await expect(card.getByText(/XP|총점/i)).toHaveCount(0); expect(revisionRequests).toBe(1); expect(lockRequests).toBe(1); await expectNoHorizontalOverflow(page); }); test("teacher gets cohort read and append-only correction only", async ({ page }) => { const readModel = initialReadModel("supervisor"); revealAssessment(readModel); let correctionBody: TeacherCorrectionRequest | null = null; let executionRequests = 0; await routeUnmockedApi(page); await routeAuth(page, "teacher"); await routeReviewShell(page); await page.route("**/api/teacher/dashboard", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ pending_reviews: [], recent_sessions: [{ session_id: SESSION_ID, learner_id: LEARNER_ID }], }), }), ); await page.route(`**/api/calibration/learners/${LEARNER_ID}`, (route) => fulfillReadModel(route, readModel), ); await page.route("**/api/calibration/transfer-executions", async (route) => { executionRequests += 1; await route.fulfill({ status: 500, body: "must not be called" }); }); await page.route("**/api/calibration/reviews", async (route) => { correctionBody = route.request().postDataJSON() as TeacherCorrectionRequest; expect(correctionBody.disposition).toBe("corrected"); expect(correctionBody.correction_payload).toEqual({ teacher_note: "과신 판정에 반대 근거 장면을 한 개 더 연결해야 해", }); expect(correctionBody).not.toHaveProperty("prediction_revision_id"); readModel.teacher_reviews.push({ ...correctionBody, review_no: 1, supersedes_review_id: null, created_by_uid: "51000000-0000-0000-0000-000000000002", created_by_role: "instructor", created_at: "2026-08-06T01:09:00Z", }); await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ submission_id: correctionBody.submission_id, review_id: correctionBody.review_id, review_no: 1, idempotent_replay: false, }), }); }); await page.goto(`/teach/session/${SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark")); const card = page.locator(".ct-card"); await expect(card.getByRole("heading", { name: "읽기와 교정만 가능" })).toBeVisible(); await expect(card.getByRole("button", { name: /예측.*기록|예측.*잠그기/ })).toHaveCount(0); await expect(card.getByRole("button", { name: /확정|승인/ })).toHaveCount(0); await expect(card.getByRole("link", { name: /미지 전이 과제로 연습/ })).toHaveCount(0); await card.getByLabel("교정 이유 (발화 원문 제외)").fill( "과신 판정에 반대 근거 장면을 한 개 더 연결해야 해", ); await card.getByRole("button", { name: "교정 revision 추가" }).click(); await expect(card.getByText("교수자 교정을 append-only 원장에 추가했어.")).toBeVisible(); expect(correctionBody).not.toBeNull(); expect(executionRequests).toBe(0); await expectNoHorizontalOverflow(page); }); test("실패한 미지 trial과 자기보정 처방을 키보드 실행 계약으로 온전히 인계한다", async ({ page, }) => { const readModel = initialReadModel(); revealAssessment(readModel); await routeUnmockedApi(page); await routeAuth(page, "learner"); await routeReviewShell(page); await routeLearnerReadModel(page, [SESSION_ID], (route) => fulfillReadModel(route, readModel), ); await page.goto(`/learn/session/${SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".ct-card"); const launch = card.getByRole("link", { name: "이 미지 전이 과제로 연습", }); await expect(launch).toBeVisible(); await expect(card.getByText("family-conflict · 대립")).toBeVisible(); const href = await launch.getAttribute("href"); expect(href).not.toBeNull(); const parsed = parsePracticeLaunchIntent( new URL(href!, "http://127.0.0.1").searchParams, ); expect(parsed).toEqual({ kind: "transfer", prescriptionId: "51000000-0000-0000-0000-000000000413", suiteId: TRANSFER_SUITE_ID, trialId: "51000000-0000-0000-0000-000000000515", sourceSessionId: SESSION_ID, criterionId: "competency.empathic_reflection", novelty: "unseen_transfer", mode: "counterevidence_forecast", }); await launch.focus(); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/learn\/practice\?/); const launched = new URL(page.url()); expect(launched.searchParams.get("prescription")).toBe( "51000000-0000-0000-0000-000000000413", ); expect(launched.searchParams.get("suite")).toBe(TRANSFER_SUITE_ID); expect(launched.searchParams.get("trial")).toBe( "51000000-0000-0000-0000-000000000515", ); expect(launched.searchParams.get("source_session")).toBe(SESSION_ID); expect(launched.searchParams.get("criterion")).toBe( "competency.empathic_reflection", ); expect(launched.searchParams.get("novelty")).toBe("unseen_transfer"); expect(launched.searchParams.get("mode")).toBe( "counterevidence_forecast", ); await expect( page.getByRole("heading", { name: "반대근거 예측 처방을 이어받았습니다.", }), ).toBeVisible(); await expect(page.locator(".lh-practice-launch-intent")).toContainText( "처음 보는 장면", ); await expect(page.locator(".lh-practice-launch-intent")).toContainText( "공감적 반영", ); await expect(page.locator(".lh-practice-launch-intent")).toContainText( "처음 보는 장면 실행", ); await expect(page.locator(".lh-practice-launch-intent")).not.toContainText( "51000000-0000-0000-0000-000000000515", ); await expect(page.locator(".lh-practice-launch-intent")).not.toContainText( SESSION_ID, ); }); test("종료된 후속 전이 회기는 평가 대기 후 실제 근거를 멱등 기록하고 원장을 다시 읽는다", async ({ page, }) => { const readModel = initialReadModel(); revealAssessment(readModel); let executionRequests = 0; let readRequests = 0; const unexpectedWrites: string[] = []; await page.addInitScript(() => { const audit = { getUserMedia: 0, enumerateDevices: 0 }; Object.defineProperty(globalThis, "__g5MediaAudit", { configurable: true, value: audit, }); if (!navigator.mediaDevices) { Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: {}, }); } Object.defineProperty(navigator.mediaDevices, "getUserMedia", { configurable: true, value: async () => { audit.getUserMedia += 1; throw new DOMException("G5 fixture blocks physical media", "NotAllowedError"); }, }); Object.defineProperty(navigator.mediaDevices, "enumerateDevices", { configurable: true, value: async () => { audit.enumerateDevices += 1; throw new DOMException("G5 fixture blocks physical media", "NotAllowedError"); }, }); }); page.on("request", (request) => { if ( request.method() === "POST" && !request.url().endsWith("/api/calibration/transfer-executions") ) { unexpectedWrites.push(request.url()); } }); await routeUnmockedApi(page); await routeAuth(page, "learner"); await routeReviewShell(page, PRACTICE_SESSION_ID); await routeLearnerReadModel(page, [PRACTICE_SESSION_ID], async (route) => { readRequests += 1; await fulfillReadModel(route, readModel); }); await page.route("**/api/calibration/transfer-executions", async (route) => { executionRequests += 1; const body = route.request().postDataJSON() as ActualTransferExecutionRequest; expect(body).toEqual({ original_transfer_trial_record_id: "51000000-0000-0000-0000-000000000515", practice_session_id: PRACTICE_SESSION_ID, }); if (executionRequests === 1) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "temporary ledger failure" }), }); return; } if (executionRequests === 2) { await route.fulfill({ status: 422, contentType: "application/json", body: JSON.stringify({ detail: "actual transfer practice evaluation must be ready at session_end", }), }); return; } const response = actualTransferResponse(executionRequests > 3); readModel.actual_executions = [response.execution]; readModel.actual_transfer_assessments = [response.assessment]; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify(response), }); }); const search = new URLSearchParams({ launch: "transfer", prescription: "51000000-0000-0000-0000-000000000413", suite: TRANSFER_SUITE_ID, trial: "51000000-0000-0000-0000-000000000515", source_session: SESSION_ID, criterion: "competency.empathic_reflection", novelty: "unseen_transfer", mode: "counterevidence_forecast", }); await page.goto( `/learn/session/${PRACTICE_SESSION_ID}/review?${search.toString()}`, ); const feedbackTab = page.getByRole("tab", { name: "피드백" }); await expect(feedbackTab).toHaveAttribute("aria-selected", "true"); await expectAtMainScrollStart( page, ".sr-practice-result-jump.is-transfer", "G5 전이 근거 진입 행동", ); await page .getByRole("button", { name: "이번 회기 전이 근거 확인" }) .click(); const observation = page.locator(".ct-actual-transfer"); await expect(observation).toBeVisible(); await expect( observation.getByRole("heading", { name: "이번 연습을 실제 전이 근거로 연결", }), ).toBeVisible(); await expect(observation).not.toContainText(PRACTICE_SESSION_ID); await expect(observation).not.toContainText( "51000000-0000-0000-0000-000000000515", ); expect(executionRequests).toBe(0); await observation .getByRole("button", { name: "이 회기를 전이 근거로 확인" }) .click(); await expect(observation).toContainText( "이번 회기를 전이 근거로 연결하지 못했어", ); await observation .getByRole("button", { name: "전이 근거 연결 다시 시도" }) .click(); await expect(observation).toContainText("평가 정리가 아직 진행 중"); await observation .getByRole("button", { name: "평가 완료 여부 다시 확인" }) .click(); await expect(observation).toContainText("이번 완료 회기를 실제 전이 근거로 기록했어"); await expect(observation).toContainText("0 → 1회"); await expect(observation).toContainText("0 → 1종"); await expect(observation).toContainText("목표 행동이 관찰됨"); await expect.poll(() => readRequests).toBeGreaterThan(1); await expect(observation).toContainText("최신 원장과 다시 맞춰 봤어"); await observation .getByRole("button", { name: "같은 회기 기록 다시 확인" }) .click(); await expect(observation).toContainText("중복 기록은 만들지 않았어"); expect(executionRequests).toBe(4); expect(unexpectedWrites).toEqual([]); expect( await page.evaluate( () => ( globalThis as typeof globalThis & { __g5MediaAudit?: { getUserMedia: number; enumerateDevices: number; }; } ).__g5MediaAudit, ), ).toEqual({ getUserMedia: 0, enumerateDevices: 0 }); await expectNoHorizontalOverflow(page); }); test("교수자·잘못된 전이 출처·다른 연습 intent는 실제 전이 쓰기를 열지 않는다", async ({ page, }) => { const readModel = initialReadModel(); revealAssessment(readModel); let executionRequests = 0; await routeUnmockedApi(page); await routeAuth(page, "learner"); await routeReviewShell(page, PRACTICE_SESSION_ID); await routeReviewShell(page, SESSION_ID); await routeLearnerReadModel(page, [SESSION_ID, PRACTICE_SESSION_ID], (route) => fulfillReadModel(route, readModel), ); await page.route("**/api/calibration/transfer-executions", async (route) => { executionRequests += 1; await route.fulfill({ status: 500, body: "must not be called" }); }); const validTransfer = new URLSearchParams({ launch: "transfer", prescription: "51000000-0000-0000-0000-000000000413", suite: TRANSFER_SUITE_ID, trial: "51000000-0000-0000-0000-000000000515", source_session: SESSION_ID, criterion: "competency.empathic_reflection", novelty: "unseen_transfer", mode: "counterevidence_forecast", }); await page.goto(`/learn/session/${SESSION_ID}/review?${validTransfer.toString()}`); await expect(page.locator(".ct-actual-transfer.is-degraded")).toContainText( "원본 과제와 분리된 후속 연습 회기에서만", ); await expect( page.locator(".ct-actual-transfer").getByRole("button"), ).toHaveCount(0); const invalidTransfer = new URLSearchParams(validTransfer); invalidTransfer.set("trial", "51000000-0000-0000-0000-000000000599"); await page.goto( `/learn/session/${PRACTICE_SESSION_ID}/review?${invalidTransfer.toString()}`, ); await expect(page.locator(".ct-actual-transfer.is-degraded")).toContainText( "현재 연결로는 기록 요청을 보내지 않아", ); await expect( page.locator(".ct-actual-transfer").getByRole("button"), ).toHaveCount(0); const deliberate = new URLSearchParams({ launch: "deliberate", prescription: "oas-g4-deliberate", source_session: SESSION_ID, criterion: "criterion.reflect-and-check", novelty: "familiar", mode: "replay", }); await page.goto( `/learn/session/${PRACTICE_SESSION_ID}/review?${deliberate.toString()}`, ); await expect(page.locator(".ct-actual-transfer")).toHaveCount(0); expect(executionRequests).toBe(0); }); test("모바일에서 전이 실행 티켓은 44px 터치 목표와 무가로넘침을 유지한다", async ({ page, }) => { await page.setViewportSize({ width: 390, height: 844 }); const readModel = initialReadModel(); revealAssessment(readModel); await routeUnmockedApi(page); await routeAuth(page, "learner"); await routeReviewShell(page); await routeLearnerReadModel(page, [SESSION_ID], (route) => fulfillReadModel(route, readModel), ); await page.goto(`/learn/session/${SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const launch = page.getByRole("link", { name: "이 미지 전이 과제로 연습", }); await expect(launch).toBeVisible(); const box = await launch.boundingBox(); expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); await expectNoHorizontalOverflow(page); }); test("loading·API error·계약 degraded를 성공 상태로 위장하지 않는다", async ({ page, }) => { const degraded = initialReadModel(); revealAssessment(degraded); degraded.calibration_assessments = []; let responseMode: "pending" | "error" | "degraded" = "pending"; let releaseRequest = () => {}; const firstResponse = new Promise((resolve) => { releaseRequest = resolve; }); await routeUnmockedApi(page); await routeAuth(page, "learner"); await routeReviewShell(page); await routeLearnerReadModel(page, [SESSION_ID], async (route) => { if (responseMode === "pending") { await firstResponse; } if (responseMode === "error") { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "캘리브레이션 원장 연결 지연" }), }); return; } await fulfillReadModel(route, degraded); }); await page.goto(`/learn/session/${SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".ct-card"); await expect(card).toHaveAttribute("aria-busy", "true"); await expect(card).toHaveAttribute( "aria-label", "캘리브레이션과 전이 원장 불러오는 중", ); responseMode = "error"; releaseRequest(); await expect( card.getByRole("heading", { name: "예측 원장을 표시할 수 없어" }), ).toBeVisible(); await expect(card).toContainText("API 503"); responseMode = "degraded"; await card.getByRole("button", { name: "다시 불러오기" }).click(); await expect(card.locator(".ct-transfer-launch-degraded")).toContainText( "전이 trial과 자기보정 처방의 연결을 확인할 수 없어", ); await expect( card.getByRole("link", { name: "이 미지 전이 과제로 연습" }), ).toHaveCount(0); }); });