/** * uc-practice-g4-g5.spec.ts — G4 처방·재연습 / G5 자기평가 잠금·전이 유스케이스 스펙. * * 목적: 회기 리뷰의 숙의 연습 카드(G4)와 캘리브레이션·전이 카드(G5)에서 * 실제 학습자가 할 법한 행동(처방 CTA 이동, 재연습 근거 반영 전/후 비교, * 멱등 재확인, mastered 비임상 고지, 예측 잠금 순서, 전이 intent 쿼리 보존, * 홈 재연습 인계 패널)을 검증한다. * * 근거: apps/web/src/pages/session-review/DeliberatePracticeCard.tsx, * CalibrationTransferCard.tsx, src/lib/practiceLaunchIntent.ts, * src/pages/SessionReview.tsx(sr-practice-return), src/pages/LearnerHome.tsx * (lh-practice-launch-intent)의 실제 UI 계약만 어설션한다. * * 모든 응답은 route fixture다. 실제 AI 엔진 턴 생성 없음, 실 API 호출 없음. * 중복 회피: deliberate-practice.spec.ts(우선 처방 href 파싱·키보드 진입), * calibration-transfer.spec.ts(revise→lock→reveal 전체 루프, 0→1 기록), * self-directed-learning-loop.spec.ts(홈→회기→리뷰→재연습 전체 여정)와 * 겹치지 않도록 각 테스트는 다른 각도(개별 CTA 클릭 이동, 1→1 멱등 표시, * mastered 게이트, 잠금 후 폼 소멸, 재실행 버튼 쿼리 보존)를 다룬다. */ import { expect, test, type Page, type Route } from "@playwright/test"; import type { DeliberatePracticeReadModel, PracticeEpisodeItem, PracticePrescriptionItem, } from "../src/pages/session-review/deliberatePracticeApi"; import type { ActualTransferExecutionResponse, CalibrationTransferReadModelResponse, } from "../src/pages/session-review/calibrationTransferApi"; import { filledReviewResponse, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; type PracticeMode = PracticePrescriptionItem["activity_mode"]; const LEARNER_ID = "61000000-0000-4000-8000-000000000001"; const SRC_SESSION_ID = "61000000-0000-4000-8000-0000000000a1"; const RETRY_SESSION_ID = "61000000-0000-4000-8000-0000000000a2"; const TURN_UUIDS = [ "62000000-0000-4000-8000-000000000001", "62000000-0000-4000-8000-000000000002", "62000000-0000-4000-8000-000000000003", "62000000-0000-4000-8000-000000000004", "62000000-0000-4000-8000-000000000005", "62000000-0000-4000-8000-000000000006", ]; const G4_MODES: PracticeMode[] = [ "replay", "branch", "constrained_response", "voice_retry", "difficulty_ladder", ]; const G4_PRESCRIPTION_ID = (mode: PracticeMode) => `uc-g4-${mode}`; const G4_CRITERION_ID = "criterion.reflect-and-check"; const G5_HISTORY_ID = "63000000-0000-4000-8000-000000000210"; const G5_REVISION_ID = "63000000-0000-4000-8000-000000000211"; const G5_PRESCRIPTION_ID = "63000000-0000-4000-8000-000000000413"; const G5_SUITE_ID = "63000000-0000-4000-8000-000000000510"; const G5_TRIAL_ID = "63000000-0000-4000-8000-000000000515"; async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } // --------------------------------------------------------------------------- // G4 read model fixture // --------------------------------------------------------------------------- function g4Activity(mode: PracticeMode, index: number) { const common = { scenario_variant_id: `uc-variant-${mode}`, scenario_novelty: "familiar" as const, difficulty_level: index, }; if (mode === "replay") { return { ...common, mode, launch_intent: "practice.replay.launch" as const, pause_at_evidence_ref: TURN_UUIDS[1], }; } if (mode === "branch") { return { ...common, mode, launch_intent: "practice.branch.launch" as const, branch_options: ["감정 확인", "의미 확인"], client_responses_hidden: true as const, }; } if (mode === "constrained_response") { return { ...common, mode, launch_intent: "practice.constrained-response.launch" as const, required_moves: ["정서 반영", "이해 확인"], max_words: 24, }; } if (mode === "voice_retry") { return { ...common, mode, launch_intent: "practice.voice-retry.launch" as const, acoustic_focus: ["쉼", "말 속도"], max_seconds: 20, }; } return { ...common, mode, launch_intent: "practice.difficulty-ladder.launch" as const, steps: [ { level: 1, scenario_variant_id: "uc-ladder-familiar", scenario_novelty: "familiar" as const, variation: "내담자가 짧게 답하는 장면", }, { level: 2, scenario_variant_id: "uc-ladder-unseen", scenario_novelty: "unseen_transfer" as const, variation: "내담자가 개입 의도를 되묻는 장면", }, ], }; } function g4Prescription( mode: PracticeMode, index: number, ): PracticePrescriptionItem { const prescriptionId = G4_PRESCRIPTION_ID(mode); const competencyId = `competency.uc_${mode}`; const observable = `${mode} 장면에서 감정을 반영한 뒤 이해가 맞는지 확인한다.`; return { prescription_record_id: `64000000-0000-4000-8000-00000000000${index}`, prescription_key: prescriptionId, session_id: SRC_SESSION_ID, competency_id: competencyId, criterion_id: G4_CRITERION_ID, observable_behavior: observable, activity_mode: mode, scenario_variant_id: `uc-variant-${mode}`, scenario_novelty: "familiar", difficulty_level: index, prescription_payload: { schema_version: "vignette.practice-prescription.v1", event_name: "practice.prescribed", prescription_id: prescriptionId, coaching_card_id: `uc-g4-card-${mode}`, scene_id: `uc-scene-${mode}`, competency_id: competencyId, criterion_id: G4_CRITERION_ID, observable_behavior: observable, activity: g4Activity(mode, index), can_launch: true, evidence_refs: [ { ref_id: TURN_UUIDS[1], scene_id: "uc-review-scene", turn_index: 2, actor: "learner", kind: "learner_behavior", }, { ref_id: TURN_UUIDS[2], scene_id: "uc-review-scene", turn_index: 3, actor: "client", kind: "client_response", }, ], source_refs: ["synthetic:uc-g4-e2e:v1"], uncertainty: 0.3, counterevidence: [], }, coach_claim: "같은 행동이 다른 장면에서도 유지되는지 확인합니다.", card_key: `uc-g4-card-${mode}`, evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], source_refs: ["synthetic:uc-g4-e2e:v1"], uncertainty: 0.3, counterevidence: [], created_at: "2026-08-10T10:00:00Z", }; } function g4Episode( sessionId: string, progress: PracticeEpisodeItem["progress"], ): PracticeEpisodeItem { return { episode_submission_id: "65000000-0000-4000-8000-000000000001", episode_key: `uc-episode-${progress}`, session_id: sessionId, progress, mastery_allowed: progress === "mastered", mastery_blockers: progress === "mastered" ? [] : ["unseen_transfer_not_verified"], uncertainty: 0.25, evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], counterevidence: [], assessment_payload: { prescription_id: G4_PRESCRIPTION_ID("replay"), competency_id: "competency.uc_replay", } as unknown as PracticeEpisodeItem["assessment_payload"], created_at: "2026-08-10T10:05:00Z", attempts: [ { attempt_record_id: "66000000-0000-4000-8000-000000000001", attempt_key: "uc-attempt-1", episode_submission_id: "65000000-0000-4000-8000-000000000001", sequence_no: 1, scenario_variant_id: "uc-variant-replay", scenario_novelty: "familiar", difficulty_level: 1, criterion_status: "observed", client_response: "engaged", outcome: "passed", utterance_template_id: "utterance-sha256:uc-fixture", learner_claimed_success: false, uncertainty: 0.25, evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], counterevidence: [], attempt_payload: {}, created_at: "2026-08-10T10:05:00Z", corrections: [], }, ], }; } function g4ReadModel(options?: { episodes?: PracticeEpisodeItem[]; familiarDemonstrations?: number; }): DeliberatePracticeReadModel { const familiar = options?.familiarDemonstrations ?? 0; return { learner_id: LEARNER_ID, clinical_claim_allowed: false, prescriptions: G4_MODES.map((mode, index) => g4Prescription(mode, index + 1), ), episodes: options?.episodes ?? [], competency_graph: { schema_version: "vignette.competency-graph.v1", definitions: G4_MODES.map((mode) => ({ competency_id: `competency.uc_${mode}`, label_ko: mode === "replay" ? "공감적 반영 후 확인" : `${mode} 역량`, description: "하나의 행동을 장면에서 유지하는 연습 역량입니다.", prerequisite_ids: [], })), states: G4_MODES.map((mode, index) => ({ competency_id: `competency.uc_${mode}`, band: mode === "replay" ? "fragile" : "developing", forgetting_risk: mode === "replay" ? 0.8 : 0.4 - index * 0.03, uncertainty: 0.3, attempt_count: mode === "replay" ? familiar : 0, familiar_demonstrations: mode === "replay" ? familiar : 0, unseen_transfer_demonstrations: 0, highest_familiar_difficulty: mode === "replay" ? 1 : 0, evidence_refs: [], counterevidence: [], })), }, snapshot_id: "67000000-0000-4000-8000-000000000001", snapshot_no: 1, next_practice: { schema_version: "vignette.curriculum-decision.v1", selected_prescription_id: G4_PRESCRIPTION_ID("replay"), competency_id: "competency.uc_replay", competency_band: "fragile", forgetting_risk: 0.8, mode: "replay", selection_basis: [ "weakest_available_band:fragile", "scenario_novelty:familiar", ], deferred_prescription_ids: G4_MODES.slice(1).map(G4_PRESCRIPTION_ID), blocked_prescription_reasons: [], }, decision_id: "68000000-0000-4000-8000-000000000001", }; } // --------------------------------------------------------------------------- // G5 read model fixture // --------------------------------------------------------------------------- function g5ReadModel(options?: { locked?: boolean; withSuite?: boolean; }): CalibrationTransferReadModelResponse { const model: CalibrationTransferReadModelResponse = { learner_id: LEARNER_ID, requested_view: "learner", clinical_claim_allowed: false, prediction_histories: [ { history_id: G5_HISTORY_ID, session_id: SRC_SESSION_ID, competency_id: "competency.empathic_reflection", practice_block_id: "uc-g5-block-001", scenario_variant_id: "uc-school-refusal", phrase_family_id: "uc-reflection-a", created_at: "2026-08-10T01:00:00Z", revisions: [ { prediction_revision_id: G5_REVISION_ID, submission_id: "63000000-0000-4000-8000-000000000212", history_id: G5_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-10T01:00:00Z", }, ], lock: options?.locked ? { lock_id: "63000000-0000-4000-8000-000000000310", submission_id: "63000000-0000-4000-8000-000000000311", history_id: G5_HISTORY_ID, prediction_revision_id: G5_REVISION_ID, locked_sequence: 2, created_at: "2026-08-10T01:02:00Z", } : null, external_observation: null, }, ], calibration_assessments: [], transfer_suites: options?.withSuite ? [ { transfer_suite_record_id: G5_SUITE_ID, submission_id: "63000000-0000-4000-8000-000000000511", suite_key: "uc-g5-suite-001", session_id: SRC_SESSION_ID, training_phrase_family_ids: ["uc-memorized-a"], model_run_id: "63000000-0000-4000-8000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", data_classification: "synthetic_educational", clinical_claim_allowed: false, created_at: "2026-08-10T01:05:00Z", trials: [ { transfer_trial_record_id: G5_TRIAL_ID, transfer_suite_record_id: G5_SUITE_ID, trial_key: "uc-g5-transfer-001", 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: "uc-novel-b", status: "failed", uncertainty: 0.22, evidence_turn_ids: ["63000000-0000-4000-8000-000000000516"], counterevidence: ["client_rejected_reflection"], model_run_id: "63000000-0000-4000-8000-000000000512", instrument_id: "vignette.unseen-transfer", instrument_version: "1.0.0", created_at: "2026-08-10T01:06:00Z", }, ], assessments: [], drift_reports: [], }, ] : [], teacher_reviews: [], actual_executions: [], actual_transfer_assessments: [], }; return model; } function g5EmptyReadModel(): CalibrationTransferReadModelResponse { return { learner_id: LEARNER_ID, requested_view: "learner", clinical_claim_allowed: false, prediction_histories: [], calibration_assessments: [], transfer_suites: [], teacher_reviews: [], actual_executions: [], actual_transfer_assessments: [], }; } function actualTransferResponse( idempotentReplay: boolean, ): ActualTransferExecutionResponse { return { execution: { execution_event_id: "63000000-0000-4000-8000-000000000611", original_transfer_trial_record_id: G5_TRIAL_ID, practice_session_id: RETRY_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: "uc-novel-c", }, training_phrase_collision: false, status: "passed", uncertainty: 0.18, evidence_turn_ids: ["63000000-0000-4000-8000-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: "63000000-0000-4000-8000-000000000613", created_at: "2026-08-10T02: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_observed_executions_below_minimum:1/6"], source_execution_event_ids: ["63000000-0000-4000-8000-000000000611"], evidence_turn_ids: ["63000000-0000-4000-8000-000000000612"], }, idempotent_replay: idempotentReplay, }; } // --------------------------------------------------------------------------- // routing helpers (mocked-auth 패턴: catch-all 먼저, 개별 fixture 나중) // --------------------------------------------------------------------------- async function routeBase(page: Page, reviewSessionId: string) { await page.route("**/api/**", (route) => fulfillJson(route, { detail: "not part of this focused fixture" }, 404), ); await page.route("**/api/auth/me", (route) => fulfillJson(route, { user_id: LEARNER_ID, email: "learner@hs.ac.kr", role: "learner", display_name: "UC 학습자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: ["uc-e2e"], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "UC 학습자", self_introduction: "", avatar_url: "", }), ); const review = filledReviewResponse(reviewSessionId); review.turns = review.turns.map((turn, index) => ({ ...turn, turn_id: TURN_UUIDS[index], })); await page.route(`**/api/sessions/${reviewSessionId}/review`, (route) => fulfillJson(route, review), ); await page.route( `**/api/sessions/${reviewSessionId}/alliance-pulses`, (route) => fulfillJson(route, { items: [] }), ); for (const suffix of ["outcome-trajectory", "ruptures"]) { await page.route( `**/api/sessions/${reviewSessionId}/${suffix}`, (route) => fulfillJson(route, { detail: "not found" }, 404), ); } await routePrepostMeasures(page); } async function routeG4( page: Page, model: () => DeliberatePracticeReadModel, ) { await page.route("**/api/practice/learners/me", (route) => fulfillJson(route, model()), ); await page.route("**/api/calibration/learners/me", (route) => fulfillJson(route, g5EmptyReadModel()), ); } async function routeG5( page: Page, model: () => CalibrationTransferReadModelResponse, ) { await page.route("**/api/calibration/learners/me", (route) => fulfillJson(route, model()), ); } async function openFeedback(page: Page, path: string) { await page.goto(path); await page.getByRole("tab", { name: "피드백" }).click(); } function deliberateSearch(): string { return new URLSearchParams({ launch: "deliberate", prescription: G4_PRESCRIPTION_ID("replay"), source_session: SRC_SESSION_ID, criterion: G4_CRITERION_ID, novelty: "familiar", mode: "replay", }).toString(); } function transferSearch(): string { return new URLSearchParams({ launch: "transfer", prescription: G5_PRESCRIPTION_ID, suite: G5_SUITE_ID, trial: G5_TRIAL_ID, source_session: SRC_SESSION_ID, criterion: "competency.empathic_reflection", novelty: "unseen_transfer", mode: "counterevidence_forecast", }).toString(); } async function clickPracticeCta( page: Page, mode: PracticeMode, ): Promise { await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`); const card = page.locator(".dp-card"); await expect(card).toBeVisible(); if (mode === "replay") { await card.getByRole("link", { name: "이 처방으로 연습 시작" }).click(); return; } const queueLabel: Record = { branch: "반응 분기 연습 열기", constrained_response: "제약 응답 연습 열기", voice_retry: "음성 재시도 연습 열기", difficulty_ladder: "난도 단계 연습 열기", }; await card.getByText("뒤에 대기 중인 연습 4개").click(); await card.getByRole("link", { name: queueLabel[mode] }).click(); } const MODE_HEADING: Record = { replay: "장면 다시 보기 처방을 이어받았습니다.", branch: "다른 반응 분기 처방을 이어받았습니다.", constrained_response: "제약 응답 처방을 이어받았습니다.", voice_retry: "음성 재시도 처방을 이어받았습니다.", difficulty_ladder: "난도 사다리 처방을 이어받았습니다.", }; async function expectLandedOnPractice(page: Page, mode: PracticeMode) { await expect(page).toHaveURL(/\/learn\/practice\?/); const url = new URL(page.url()); expect(url.searchParams.get("launch")).toBe("deliberate"); expect(url.searchParams.get("prescription")).toBe(G4_PRESCRIPTION_ID(mode)); expect(url.searchParams.get("source_session")).toBe(SRC_SESSION_ID); expect(url.searchParams.get("criterion")).toBe(G4_CRITERION_ID); expect(url.searchParams.get("novelty")).toBe("familiar"); expect(url.searchParams.get("mode")).toBe(mode); await expect( page.getByRole("heading", { name: MODE_HEADING[mode] }), ).toBeVisible(); } test.describe("UC G4·G5 — 처방 재연습과 자기평가 잠금·전이 여정", () => { // usecase: 학습자가 우선 처방(되감기) CTA를 눌러 처방 조건을 유지한 채 연습 화면으로 이동한다 test("되감기 처방 CTA를 누르면 처방·기준·장면 조건이 보존된 연습 화면으로 이동한다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG4(page, () => g4ReadModel()); await clickPracticeCta(page, "replay"); await expectLandedOnPractice(page, "replay"); }); // usecase: 학습자가 대기 큐에서 반응 분기 처방을 골라 연습을 시작한다 test("대기 큐의 반응 분기 CTA는 branch 모드 쿼리로 연습 화면을 연다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG4(page, () => g4ReadModel()); await clickPracticeCta(page, "branch"); await expectLandedOnPractice(page, "branch"); }); // usecase: 학습자가 대기 큐에서 제약 응답 처방을 골라 연습을 시작한다 test("대기 큐의 제약 응답 CTA는 constrained_response 모드 쿼리로 연습 화면을 연다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG4(page, () => g4ReadModel()); await clickPracticeCta(page, "constrained_response"); await expectLandedOnPractice(page, "constrained_response"); }); // usecase: 학습자가 대기 큐에서 음성 재시도 처방을 골라 연습을 시작한다 test("대기 큐의 음성 재시도 CTA는 voice_retry 모드 쿼리로 연습 화면을 연다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG4(page, () => g4ReadModel()); await clickPracticeCta(page, "voice_retry"); await expectLandedOnPractice(page, "voice_retry"); }); // usecase: 학습자가 대기 큐에서 난도 단계 처방을 골라 연습을 시작한다 test("대기 큐의 난도 단계 CTA는 difficulty_ladder 모드 쿼리로 연습 화면을 연다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG4(page, () => g4ReadModel()); await clickPracticeCta(page, "difficulty_ladder"); await expectLandedOnPractice(page, "difficulty_ladder"); }); // usecase: 재연습 회기를 마치고 돌아온 학습자가 독립 관찰 반영 전/후 근거 횟수 비교를 확인한다 test("재연습 복귀 리뷰에서 독립 관찰을 반영하면 반영 전 1회와 반영 후 2회가 나란히 표시된다", async ({ page, }) => { let observed = false; await routeBase(page, RETRY_SESSION_ID); await routeG4(page, () => observed ? g4ReadModel({ episodes: [g4Episode(RETRY_SESSION_ID, "transfer_pending")], familiarDemonstrations: 2, }) : g4ReadModel({ familiarDemonstrations: 1 }), ); await page.route( `**/api/practice/${G4_PRESCRIPTION_ID("replay")}/attempts/from-session/${RETRY_SESSION_ID}`, (route) => { observed = true; return fulfillJson( route, { submission_id: "69000000-0000-4000-8000-000000000001", progress: "transfer_pending", mastery_allowed: false, snapshot_id: "69000000-0000-4000-8000-000000000002", decision_id: "69000000-0000-4000-8000-000000000003", next_prescription_id: G4_PRESCRIPTION_ID("replay"), idempotent_replay: false, }, 201, ); }, ); await page.goto( `/learn/session/${RETRY_SESSION_ID}/review?${deliberateSearch()}`, ); await expect(page.getByRole("tab", { name: "피드백" })).toHaveAttribute( "aria-selected", "true", ); const observation = page.locator(".dp-runtime-observation"); await expect(observation).toBeVisible(); await expect(observation).toContainText("반영 전"); await expect(observation).toContainText("익숙한 장면 근거 1회"); await expect(observation).toContainText("학습자만 실행"); await observation .getByRole("button", { name: "이번 회기를 독립 관찰로 반영" }) .click(); await expect(observation).toContainText("서버 독립 관찰 반영 완료"); await expect(observation).toContainText("반영 후"); await expect(observation).toContainText("익숙한 장면 근거 2회"); await expect(observation).toContainText("원장 반영됨"); await expectNoHorizontalOverflow(page); }); // usecase: 전이 근거를 이미 기록한 학습자가 같은 버튼을 다시 눌러도 횟수가 늘지 않음을 확인한다 test("G5 실제 전이 근거를 재확인하면 1→1회로 유지되고 중복 기록을 만들지 않는다", async ({ page, }) => { const model = g5ReadModel({ withSuite: true }); const bodies: Array> = []; await routeBase(page, RETRY_SESSION_ID); await routeG5(page, () => model); await page.route("**/api/calibration/transfer-executions", (route) => { bodies.push(route.request().postDataJSON() as Record); const response = actualTransferResponse(bodies.length > 1); model.actual_executions = [response.execution]; model.actual_transfer_assessments = [response.assessment]; return fulfillJson(route, response, 201); }); await page.goto( `/learn/session/${RETRY_SESSION_ID}/review?${transferSearch()}`, ); const observation = page.locator(".ct-actual-transfer"); await expect(observation).toBeVisible(); await observation .getByRole("button", { name: "이 회기를 전이 근거로 확인" }) .click(); await expect(observation).toContainText( "이번 완료 회기를 실제 전이 근거로 기록했어", ); await expect(observation).toContainText("0 → 1회"); await observation .getByRole("button", { name: "같은 회기 기록 다시 확인" }) .click(); await expect(observation).toContainText("중복 기록은 만들지 않았어"); await expect(observation).toContainText("1 → 1회"); expect(bodies).toHaveLength(2); expect(bodies[0]).toEqual(bodies[1]); expect(bodies[0]).toEqual({ original_transfer_trial_record_id: G5_TRIAL_ID, practice_session_id: RETRY_SESSION_ID, }); }); // usecase: 전이까지 확인한 학습자가 mastered 판정이 임상 숙련 주장이 아님을 게이트에서 확인한다 test("mastered 게이트는 새 장면 전이 확인과 함께 총점·임상 숙련 합산 금지를 명시한다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG4(page, () => g4ReadModel({ episodes: [g4Episode(SRC_SESSION_ID, "mastered")], familiarDemonstrations: 1, }), ); await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`); const gate = page.locator(".dp-gate--mastered"); await expect(gate).toBeVisible(); await expect(gate).toContainText("새 장면 전이 확인"); await expect(gate).toContainText( "익숙한 장면과 새 장면에서 행동이 확인됐습니다", ); await expect(gate).toContainText( "총점이나 임상적 숙련도 판정으로 합산하지 않습니다", ); await expect( page.locator(".dp-card").getByText(/총점\s*[:·]\s*\d|XP\s*\d|경험치\s*\d/), ).toHaveCount(0); }); // usecase: 예측을 잠근 학습자는 더 이상 수정하지 못하고 독립 관찰 공개만 기다린다 test("예측을 잠근 뒤에는 revision 폼과 잠금 UI가 사라지고 외부 관찰 대기 상태가 표시된다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG5(page, () => g5ReadModel({ locked: true })); await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`); const card = page.locator(".ct-card"); await expect(card.getByText("예측 잠금 완료")).toBeVisible(); await expect(card.getByText("외부 관찰을 기다리는 중")).toBeVisible(); await expect( card.getByText("잠근 예측은 수정할 수 없어", { exact: false }), ).toBeVisible(); await expect( card.getByRole("button", { name: /예측 수정 기록|첫 예측 기록/ }), ).toHaveCount(0); await expect( card.getByRole("button", { name: "이 예측 잠그기" }), ).toHaveCount(0); }); // usecase: 아직 잠그지 않은 학습자는 독립 관찰이 가려져 있고 확인 없이는 잠그지 못한다 test("잠금 전에는 독립 관찰이 가려지고 확인 해제 시 잠그기 버튼이 다시 닫힌다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await routeG5(page, () => g5ReadModel()); await openFeedback(page, `/learn/session/${SRC_SESSION_ID}/review`); const card = page.locator(".ct-card"); await expect(card.getByText("잠금 전", { exact: true })).toBeVisible(); await expect(card.getByText("아직 공개하지 않음")).toBeVisible(); await expect(card.getByText("예측을 잠가야 비교 가능")).toBeVisible(); await expect(card.getByText("공개 전", { exact: true })).toBeVisible(); const lockButton = card.getByRole("button", { name: "이 예측 잠그기" }); const confirm = card.getByLabel("잠근 뒤 수정할 수 없음을 확인했어"); await expect(lockButton).toBeDisabled(); await confirm.check(); await expect(lockButton).toBeEnabled(); await confirm.uncheck(); await expect(lockButton).toBeDisabled(); }); // usecase: 전이 과제 수행을 마친 학습자가 리뷰 상단에서 같은 전이 과제로 재실행한다 test("전이 복귀 리뷰의 재실행 버튼은 suite·trial 쿼리를 보존한 채 연습 화면으로 이동한다", async ({ page, }) => { await routeBase(page, RETRY_SESSION_ID); await routeG5(page, () => g5ReadModel({ withSuite: true })); await page.goto( `/learn/session/${RETRY_SESSION_ID}/review?${transferSearch()}`, ); const returnCard = page.locator(".sr-practice-return.is-transfer"); await expect(returnCard).toBeVisible(); await expect(returnCard).toContainText("전이 검증 결과"); await expect(returnCard).toContainText( "반대근거 예측 수행 회기의 리뷰입니다.", ); await expect(returnCard).toContainText("처음 보는 장면"); await expect(returnCard).toContainText("공감적 반영"); await expect(returnCard).toContainText("원본 회기 참조"); await returnCard .getByRole("button", { name: "같은 전이 과제로 다시 연습" }) .click(); await expect(page).toHaveURL(/\/learn\/practice\?/); const url = new URL(page.url()); expect(url.searchParams.get("launch")).toBe("transfer"); expect(url.searchParams.get("prescription")).toBe(G5_PRESCRIPTION_ID); expect(url.searchParams.get("suite")).toBe(G5_SUITE_ID); expect(url.searchParams.get("trial")).toBe(G5_TRIAL_ID); expect(url.searchParams.get("source_session")).toBe(SRC_SESSION_ID); expect(url.searchParams.get("novelty")).toBe("unseen_transfer"); expect(url.searchParams.get("mode")).toBe("counterevidence_forecast"); await expect( page.getByRole("heading", { name: "반대근거 예측 처방을 이어받았습니다.", }), ).toBeVisible(); }); // usecase: 처방 연습을 마친 학습자가 리뷰 상단 결과 카드에서 같은 처방으로 재실행한다 test("처방 복귀 리뷰의 결과 카드는 장면·기준·출처를 표시하고 같은 처방 쿼리로 재이동한다", async ({ page, }) => { await routeBase(page, RETRY_SESSION_ID); await routeG4(page, () => g4ReadModel({ familiarDemonstrations: 1 })); await page.goto( `/learn/session/${RETRY_SESSION_ID}/review?${deliberateSearch()}`, ); const returnCard = page.locator(".sr-practice-return.is-deliberate"); await expect(returnCard).toBeVisible(); await expect(returnCard).toContainText("처방 연습 결과"); await expect(returnCard).toContainText( "장면 다시 보기 수행 회기의 리뷰입니다.", ); await expect(returnCard).toContainText("익숙한 장면"); await expect(returnCard).toContainText("감정 반영 후 이해 확인"); await expect(returnCard).toContainText("원본 회기 참조"); await returnCard .getByRole("button", { name: "같은 처방으로 다시 연습" }) .click(); await expect(page).toHaveURL(/\/learn\/practice\?/); const url = new URL(page.url()); expect(url.searchParams.get("launch")).toBe("deliberate"); expect(url.searchParams.get("prescription")).toBe( G4_PRESCRIPTION_ID("replay"), ); expect(url.searchParams.get("source_session")).toBe(SRC_SESSION_ID); expect(url.searchParams.get("criterion")).toBe(G4_CRITERION_ID); expect(url.searchParams.get("mode")).toBe("replay"); }); // usecase: 학습자가 북마크한 전이 인계 URL로 홈 연습 화면에 직접 진입해 처방 큐 패널을 확인한다 test("홈 연습 화면에 전이 intent로 직접 진입하면 전이 검증 패널과 과제 조건이 노출된다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await page.goto(`/learn/practice?${transferSearch()}`); const panel = page.locator(".lh-practice-launch-intent"); await expect(panel).toBeVisible(); await expect(panel).toContainText("전이 검증"); await expect( page.getByRole("heading", { name: "반대근거 예측 처방을 이어받았습니다.", }), ).toBeVisible(); await panel.locator("summary").click(); await expect(panel).toContainText("처음 보는 장면 실행"); await expect(panel).toContainText("원본 회기 참조"); await expect(panel).toContainText("공감적 반영"); await expect(panel).not.toContainText(G5_TRIAL_ID); await expect(panel).not.toContainText(SRC_SESSION_ID); }); // usecase: 훼손된 처방 URL로 진입한 학습자는 일반 연습으로 우회하지 못하고 오류 안내를 받는다 test("훼손된 launch 쿼리로 홈에 진입하면 처방 연결 오류가 뜨고 새 회기 시작이 잠긴다", async ({ page, }) => { await routeBase(page, SRC_SESSION_ID); await page.route("**/api/personas", (route) => fulfillJson(route, [ { code: "P1", display_name: "민서(가명) · 17세 · 학교 적응 어려움", difficulty: "hard", theory_target: ["humanistic"], demographics: { age_band: "10대" }, presenting_summary: "학교 적응과 무기력을 둘러싼 상담 연습", voice_preset: "soft-young-fem", source: "database", degraded: false, }, ]), ); const tampered = new URLSearchParams({ launch: "deliberate", prescription: G4_PRESCRIPTION_ID("replay"), source_session: SRC_SESSION_ID, criterion: G4_CRITERION_ID, novelty: "familiar", mode: "teleport", }); await page.goto(`/learn/practice?${tampered.toString()}`); const alert = page.locator(".lh-voice-practice-intent.is-error"); await expect(alert).toBeVisible(); await expect(alert).toContainText( "연습 처방의 출처를 검증할 수 없습니다.", ); await expect(alert).toContainText("일반 연습으로 바꾸지 않았습니다."); await page.getByRole("option", { name: /P1/ }).click(); await expect( page.getByRole("button", { name: "새 회기 시작" }), ).toBeDisabled(); }); });