import { expect, test, type Page, type Route } from "@playwright/test"; import type { DeliberatePracticeReadModel, PracticeEpisodeItem, PracticePrescriptionItem, } from "../src/pages/session-review/deliberatePracticeApi"; import { parsePracticeLaunchIntent, practiceLaunchSearch, } from "../src/lib/practiceLaunchIntent"; import { FILLED_REVIEW_SESSION_ID, filledReviewResponse, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; type ReviewRole = "learner" | "teacher"; type PracticeMode = PracticePrescriptionItem["activity_mode"]; const LEARNER_ID = "51000000-0000-4000-8000-000000000001"; const TURN_UUIDS = [ "52000000-0000-4000-8000-000000000001", "52000000-0000-4000-8000-000000000002", "52000000-0000-4000-8000-000000000003", "52000000-0000-4000-8000-000000000004", "52000000-0000-4000-8000-000000000005", "52000000-0000-4000-8000-000000000006", ]; async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } function activityFor(mode: PracticeMode, index: number) { const common = { scenario_variant_id: `variant-${mode}-${index}`, 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: "ladder-familiar", scenario_novelty: "familiar" as const, variation: "내담자가 짧게 답하는 장면", }, { level: 2, scenario_variant_id: "ladder-unseen", scenario_novelty: "unseen_transfer" as const, variation: "내담자가 개입 의도를 되묻는 장면", }, ], }; } function prescription( mode: PracticeMode, index: number, ): PracticePrescriptionItem { const competencyId = mode === "replay" ? "competency.pacing" : `competency.${mode}`; const prescriptionId = `oas-g4-practice-${mode}`; const criterionId = `criterion.${mode}`; const observable = mode === "replay" ? "조언을 제시하기 전에 내담자의 준비도를 한 문장으로 확인한다." : `${mode} 장면에서 내담자의 반응을 확인하는 한 행동을 수행한다.`; return { prescription_record_id: `53000000-0000-4000-8000-00000000000${index}`, prescription_key: prescriptionId, session_id: FILLED_REVIEW_SESSION_ID, competency_id: competencyId, criterion_id: criterionId, observable_behavior: observable, activity_mode: mode, scenario_variant_id: `variant-${mode}-${index}`, scenario_novelty: "familiar", difficulty_level: index, prescription_payload: { schema_version: "vignette.practice-prescription.v1", event_name: "practice.prescribed", prescription_id: prescriptionId, coaching_card_id: `oas-g4-card-${mode}`, scene_id: `scene-${mode}`, competency_id: competencyId, criterion_id: criterionId, observable_behavior: observable, activity: activityFor(mode, index), can_launch: true, evidence_refs: [ { ref_id: TURN_UUIDS[1], scene_id: "review-scene", turn_index: 2, actor: "learner", kind: "learner_behavior", }, { ref_id: TURN_UUIDS[2], scene_id: "review-scene", turn_index: 3, actor: "client", kind: "client_response", }, ], source_refs: ["synthetic:g4-e2e:v1"], uncertainty: mode === "replay" ? 0.28 : 0.4, counterevidence: mode === "replay" ? ["회기 말에는 내담자가 개입 제안에 스스로 답한 장면도 있습니다."] : [], }, coach_claim: mode === "replay" ? "개입 방향은 적절했지만 내담자의 준비도를 확인하기 전에 제안이 먼저 나왔습니다." : "다른 반응 조건에서도 같은 행동이 유지되는지 확인합니다.", card_key: `oas-g4-card-${mode}`, evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], source_refs: ["synthetic:g4-e2e:v1"], uncertainty: mode === "replay" ? 0.28 : 0.4, counterevidence: mode === "replay" ? ["회기 말에는 내담자가 개입 제안에 스스로 답한 장면도 있습니다."] : [], created_at: "2026-08-06T10:00:00Z", }; } function episode(): PracticeEpisodeItem { return { episode_submission_id: "54000000-0000-4000-8000-000000000001", episode_key: "episode-replay-familiar", session_id: FILLED_REVIEW_SESSION_ID, progress: "transfer_pending", mastery_allowed: false, mastery_blockers: ["unseen_transfer_not_verified"], uncertainty: 0.28, evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], counterevidence: ["unseen_transfer_not_verified"], assessment_payload: { prescription_id: "oas-g4-practice-replay", competency_id: "competency.pacing", } as unknown as PracticeEpisodeItem["assessment_payload"], created_at: "2026-08-06T10:05:00Z", attempts: [ { attempt_record_id: "55000000-0000-4000-8000-000000000001", attempt_key: "attempt-replay-1", episode_submission_id: "54000000-0000-4000-8000-000000000001", sequence_no: 1, scenario_variant_id: "variant-replay-1", scenario_novelty: "familiar", difficulty_level: 1, criterion_status: "observed", client_response: "engaged", outcome: "passed", utterance_template_id: "utterance-sha256:e2e-familiar", learner_claimed_success: true, uncertainty: 0.28, evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], counterevidence: [], attempt_payload: {}, created_at: "2026-08-06T10:05:00Z", corrections: [], }, ], }; } function practiceReadModel(): DeliberatePracticeReadModel { const modes: PracticeMode[] = [ "replay", "branch", "constrained_response", "voice_retry", "difficulty_ladder", ]; return { learner_id: LEARNER_ID, clinical_claim_allowed: false, prescriptions: modes.map((mode, index) => prescription(mode, index + 1)), episodes: [episode()], competency_graph: { schema_version: "vignette.competency-graph.v1", definitions: modes.map((mode) => ({ competency_id: mode === "replay" ? "competency.pacing" : `competency.${mode}`, label_ko: mode === "replay" ? "개입 전 준비도 확인" : `${mode} 역량`, description: mode === "replay" ? "제안보다 먼저 내담자가 지금 다룰 준비가 되었는지 확인하는 역량입니다." : "다른 장면에서 하나의 행동을 유지하는 연습 역량입니다.", prerequisite_ids: [], })), states: modes.map((mode, index) => ({ competency_id: mode === "replay" ? "competency.pacing" : `competency.${mode}`, band: mode === "replay" ? "fragile" : "developing", forgetting_risk: mode === "replay" ? 0.82 : 0.45 - index * 0.04, uncertainty: mode === "replay" ? 0.28 : 0.4, attempt_count: mode === "replay" ? 1 : 0, familiar_demonstrations: mode === "replay" ? 1 : 0, unseen_transfer_demonstrations: 0, highest_familiar_difficulty: mode === "replay" ? 1 : 0, evidence_refs: [], counterevidence: [], })), }, snapshot_id: "56000000-0000-4000-8000-000000000001", snapshot_no: 2, next_practice: { schema_version: "vignette.curriculum-decision.v1", selected_prescription_id: "oas-g4-practice-replay", competency_id: "competency.pacing", competency_band: "fragile", forgetting_risk: 0.82, mode: "replay", selection_basis: [ "weakest_available_band:fragile", "forgetting_risk:0.820", "uncertainty:0.280", "scenario_novelty:familiar", ], deferred_prescription_ids: modes .slice(1) .map((mode) => `oas-g4-practice-${mode}`), blocked_prescription_reasons: [], }, decision_id: "57000000-0000-4000-8000-000000000001", }; } async function routeUser(page: Page, role: ReviewRole) { await page.route("**/api/auth/me", (route) => fulfillJson(route, { user_id: role === "teacher" ? "50000000-0000-4000-8000-000000000202" : LEARNER_ID, 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 routeReviewShell(page: Page, role: ReviewRole) { const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID); review.turns = review.turns.map((turn, index) => ({ ...turn, turn_id: TURN_UUIDS[index], })); if (role === "teacher") { review.teacherReview = { status: "viewed", note: "", reviewedAt: null, reviewerId: "50000000-0000-4000-8000-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 page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) => fulfillJson(route, { detail: "not found" }, 404), ); await routePrepostMeasures(page); } async function routePracticeRead(page: Page, role: ReviewRole) { if (role === "teacher") { await page.route("**/api/teacher/dashboard", (route) => fulfillJson(route, { active_sessions: 0, ended_sessions: 1, total_learners: 1, cohort_label: "E2E 한신대", message: "", source: "database", pending_reviews: [ { session_id: FILLED_REVIEW_SESSION_ID, learner_id: LEARNER_ID, }, ], recent_sessions: [], learner_growth: [], safety_alerts: [], }), ); await page.route(`**/api/practice/learners/${LEARNER_ID}`, (route) => fulfillJson(route, practiceReadModel()), ); return; } await page.route("**/api/practice/learners/me", (route) => fulfillJson(route, practiceReadModel()), ); } async function prepare(page: Page, role: ReviewRole) { await routeUser(page, role); await routeReviewShell(page, role); await routePracticeRead(page, role); } async function openPractice(page: Page, role: ReviewRole, search = "") { const root = role === "teacher" ? "/teach/session" : "/learn/session"; await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review${search}`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".dp-card"); await expect(card).toBeVisible(); return card; } test.describe("G4 숙의 연습", () => { test("학습자는 최약 역량, 다섯 방식, 원자 행동과 전이 게이트를 보고 같은 UUID로 안전하게 재시도한다", async ({ page, }, testInfo) => { await prepare(page, "learner"); const submitted: Array> = []; await page.route( "**/api/practice/oas-g4-practice-replay/attempts", async (route) => { submitted.push( route.request().postDataJSON() as Record, ); if (submitted.length === 1) { await fulfillJson( route, { detail: "연습 원장 저장소를 사용할 수 없습니다." }, 503, ); return; } await fulfillJson( route, { submission_id: submitted[1].submission_id, progress: "transfer_pending", mastery_allowed: false, snapshot_id: "58000000-0000-4000-8000-000000000001", decision_id: "59000000-0000-4000-8000-000000000001", next_prescription_id: "oas-g4-practice-replay", idempotent_replay: true, }, 201, ); }, ); const card = await openPractice(page, "learner"); await expect( card.getByRole("heading", { name: "다음 한 행동을 근거 장면에서 다시 연습합니다", }), ).toBeVisible(); await expect( card.getByLabel("숙의 연습 방식 다섯 가지").locator("li"), ).toHaveCount(5); for (const label of ["되감기", "분기", "제약 응답", "음성", "난도 단계"]) { await expect( card.getByText(label, { exact: true }).first(), ).toBeVisible(); } await expect( card.getByText("개입 전 준비도 확인", { exact: true }), ).toBeVisible(); await expect(card.getByText("망각 위험").first()).toBeVisible(); await expect(card.getByText("82%", { exact: true })).toBeVisible(); await expect(card.getByText("판정 불확실성").first()).toBeVisible(); await expect(card.getByText("28%", { exact: true }).first()).toBeVisible(); await expect( card.getByText( "조언을 제시하기 전에 내담자의 준비도를 한 문장으로 확인한다.", ), ).toBeVisible(); await expect( card.getByText("새 장면 확인 대기", { exact: true }), ).toBeVisible(); await expect( card.getByText(/익숙한 장면은 확인됐지만 전이는 아직/), ).toBeVisible(); await expect( card.getByText("반대 근거와 제한", { exact: true }), ).toBeVisible(); await expect( card.getByText(/총점\s*[:·]\s*\d|XP\s*\d|경험치\s*\d|보상\s*\d/), ).toHaveCount(0); const form = card.locator(".dp-attempt-form"); await form.getByLabel("목표 행동 관찰").selectOption("observed"); await form.getByLabel("직후 내담자 반응").selectOption("engaged"); await form .getByLabel("실제로 사용한 한 문장") .fill("지금 이 이야기를 조금 더 다뤄도 괜찮을까요?"); await form.getByLabel("내가 보기에는 목표 행동을 실행했습니다").check(); await form.getByRole("button", { name: "근거와 함께 시도 추가" }).click(); await expect(form.getByRole("alert")).toContainText("사용할 수 없습니다"); await form.getByRole("button", { name: "근거와 함께 시도 추가" }).click(); await expect(form.getByRole("status")).toContainText( "새 장면 전이가 남아 있습니다", ); expect(submitted).toHaveLength(2); expect(submitted[0]).toEqual(submitted[1]); expect(String(submitted[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, ); const episodeBody = submitted[0].episode as { episode_id: string; attempts: Array<{ attempt_id: string }>; }; expect(episodeBody.episode_id).toMatch(/^oas-g4-episode-[a-f0-9-]+$/); expect(episodeBody.attempts[0].attempt_id).toMatch( /^oas-g4-attempt-[a-f0-9-]+$/, ); await card.getByRole("button", { name: /05:04.*발화로 이동/ }).click(); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute( "aria-selected", "true", ); await expect(page.locator(".sr-turn--active")).toContainText( "혼자서 버티고", ); await page.getByRole("tab", { name: "피드백" }).click(); await card.screenshot({ path: testInfo.outputPath("deliberate-practice-learner-desktop.png"), animations: "disabled", }); await expectNoHorizontalOverflow(page); }); test("교수자는 대상 학습자 원장을 읽고 기존 시도 변경 없이 정정만 덧붙인다", async ({ page, }) => { await prepare(page, "teacher"); let runtimeObservationRequests = 0; await page.route( "**/api/practice/*/attempts/from-session/*", (route) => { runtimeObservationRequests += 1; return fulfillJson(route, { detail: "learner role required" }, 403); }, ); let correctionBody: Record | null = null; await page.route( "**/api/practice/attempts/55000000-0000-4000-8000-000000000001/correction", async (route) => { correctionBody = route.request().postDataJSON() as Record< string, unknown >; await fulfillJson( route, { submission_id: correctionBody.submission_id, correction_id: "5a000000-0000-4000-8000-000000000001", correction_no: 1, idempotent_replay: false, }, 201, ); }, ); const launchSearch = practiceLaunchSearch({ kind: "deliberate", prescriptionId: "oas-g4-practice-replay", suiteId: null, trialId: null, sourceSessionId: FILLED_REVIEW_SESSION_ID, criterionId: "criterion.replay", novelty: "familiar", mode: "replay", }); const card = await openPractice(page, "teacher", `?${launchSearch}`); await expect(card.getByText("교수자 보기", { exact: true })).toBeVisible(); await expect( card.getByText("읽기 + 정정 추가만 가능", { exact: true }), ).toBeVisible(); await expect(card.locator(".dp-attempt-form")).toHaveCount(0); await expect(card.getByRole("button", { name: /시도 추가/ })).toHaveCount( 0, ); await expect(card.locator(".dp-runtime-observation")).toHaveCount(0); await expect( card.getByRole("button", { name: /독립 관찰|평가 상태 다시 확인/ }), ).toHaveCount(0); expect(runtimeObservationRequests).toBe(0); await card.getByText("교수자 근거로 정정 추가", { exact: true }).click(); await card.getByLabel("정정 판정").selectOption("needs_retry"); await card .getByLabel("정정 사유") .fill( "후속 발화에서 준비도 확인이 유지되지 않아 다시 확인이 필요합니다.", ); await card .getByLabel("반대 근거") .fill("내담자가 짧게 동의했지만 과업 합의는 명시되지 않았습니다."); await card.getByRole("button", { name: "정정 원장에 추가" }).click(); await expect(card.getByRole("status")).toContainText( "기존 판정을 바꾸지 않고", ); expect(correctionBody).not.toBeNull(); expect(correctionBody).toMatchObject({ corrected_outcome: "needs_retry", evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]], counterevidence: [ "내담자가 짧게 동의했지만 과업 합의는 명시되지 않았습니다.", ], }); expect(String(correctionBody!.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 expectNoHorizontalOverflow(page); }); test("G5 전이 intent는 G4 독립 관찰 endpoint로 잘못 제출하지 않는다", async ({ page, }) => { await prepare(page, "learner"); let runtimeObservationRequests = 0; let calibrationReadRequests = 0; await page.route( "**/api/practice/*/attempts/from-session/*", (route) => { runtimeObservationRequests += 1; return fulfillJson(route, { detail: "wrong execution endpoint" }, 409); }, ); await page.route("**/api/calibration/learners/me", (route) => { calibrationReadRequests += 1; return fulfillJson(route, { detail: "g5 routing fixture" }, 503); }); const transferPrescriptionId = "5b000000-0000-4000-8000-000000000001"; const transferSourceSessionId = "5b000000-0000-4000-8000-000000000004"; const transferSearch = practiceLaunchSearch({ kind: "transfer", prescriptionId: transferPrescriptionId, suiteId: "5b000000-0000-4000-8000-000000000002", trialId: "5b000000-0000-4000-8000-000000000003", sourceSessionId: transferSourceSessionId, criterionId: "competency.pacing", novelty: "unseen_transfer", mode: "evidence_recall", }); const card = await openPractice(page, "learner", `?${transferSearch}`); const transferCard = page.locator(".ct-card--error"); await expect(transferCard).toContainText("예측 원장을 표시할 수 없어"); await expect(transferCard).toContainText("API 503: g5 routing fixture"); await expect(card.locator(".dp-runtime-observation")).toHaveCount(0); await expect( card.getByRole("button", { name: /독립 관찰|반영/ }), ).toHaveCount(0); await expect(card).not.toContainText(transferPrescriptionId); expect(calibrationReadRequests).toBeGreaterThan(0); expect(runtimeObservationRequests).toBe(0); }); test("다섯 처방 모드는 타입 안전한 실행 계약으로 보존되고 키보드로 연습 화면에 진입한다", async ({ page, }) => { await prepare(page, "learner"); const launchedSessionId = "5b000000-0000-4000-8000-000000000001"; 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, }, ]), ); await page.route("**/api/sessions/dashboard", (route) => fulfillJson(route, { detail: "dashboard fixture omitted" }, 503), ); await page.route("**/api/sessions", (route) => { if (route.request().method() === "POST") { return fulfillJson( route, { session_id: launchedSessionId, case_id: "g4-launch-case", session_no: 2, stage: "라포", effective_openness: 0.24, recall_summary: null, degraded: false, }, 201, ); } return fulfillJson(route, { source: "database", sessions: [ { session_id: FILLED_REVIEW_SESSION_ID, persona_code: "P1", persona_name: "민서", session_no: 1, status: "ended", stage: "정리", started_at: "2026-08-06T09:00:00Z", ended_at: "2026-08-06T10:00:00Z", review_ready: true, turn_count: 6, learner_turn_count: 3, client_turn_count: 3, archived: false, archived_at: null, }, ], }); }); await page.route(`**/api/sessions/${launchedSessionId}/alliance-pulses`, (route) => fulfillJson(route, { items: [ { pulse_id: "5c000000-0000-4000-8000-000000000001", checkpoint: "pre", status: "ready", learner_locked_at: "2026-08-07T00:00:00Z", revealed_at: "2026-08-07T00:00:01Z", error_code: null, self_scores: { goal: 0.5, task: 0.5, bond: 0.5 }, measurements: [], }, ], }), ); await page.route("**/api/voice/health", (route) => fulfillJson(route, { available: true, reason: null }), ); const card = await openPractice(page, "learner"); const primary = card.getByRole("link", { name: "이 처방으로 연습 시작" }); await expect(primary).toBeVisible(); const primaryHref = await primary.getAttribute("href"); expect(primaryHref).not.toBeNull(); const primaryUrl = new URL(primaryHref!, "http://127.0.0.1"); expect(parsePracticeLaunchIntent(primaryUrl.searchParams)).toEqual({ kind: "deliberate", prescriptionId: "oas-g4-practice-replay", suiteId: null, trialId: null, sourceSessionId: FILLED_REVIEW_SESSION_ID, criterionId: "criterion.replay", novelty: "familiar", mode: "replay", }); await card.getByText("뒤에 대기 중인 연습 4개").click(); const queued = card.getByRole("link", { name: /연습 열기/ }); await expect(queued).toHaveCount(4); const queuedModes = new Set(); for (let index = 0; index < 4; index += 1) { const href = await queued.nth(index).getAttribute("href"); const parsed = parsePracticeLaunchIntent( new URL(href!, "http://127.0.0.1").searchParams, ); expect(parsed?.kind).toBe("deliberate"); if (parsed?.kind === "deliberate") queuedModes.add(parsed.mode); } expect(queuedModes).toEqual( new Set(["branch", "constrained_response", "voice_retry", "difficulty_ladder"]), ); await primary.focus(); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/learn\/practice\?/); const launched = new URL(page.url()); expect(launched.searchParams.get("prescription")).toBe( "oas-g4-practice-replay", ); expect(launched.searchParams.get("source_session")).toBe( FILLED_REVIEW_SESSION_ID, ); expect(launched.searchParams.get("criterion")).toBe("criterion.replay"); expect(launched.searchParams.get("novelty")).toBe("familiar"); expect(launched.searchParams.get("mode")).toBe("replay"); await expect( page.getByRole("heading", { name: "장면 다시 보기 처방을 이어받았습니다." }), ).toBeVisible(); await expect(page.locator(".lh-practice-launch-intent")).toContainText( "원본 회기의 내담자를 우선 선택했으며", ); await page.getByRole("button", { name: "새 회기 시작" }).click(); await expect( page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }), ).toBeVisible(); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page).toHaveURL(new RegExp(`/learn/session/${launchedSessionId}\\?`)); const persisted = new URL(page.url()); expect(parsePracticeLaunchIntent(persisted.searchParams)).toEqual({ kind: "deliberate", prescriptionId: "oas-g4-practice-replay", suiteId: null, trialId: null, sourceSessionId: FILLED_REVIEW_SESSION_ID, criterionId: "criterion.replay", novelty: "familiar", mode: "replay", }); }); test("연습 API 불가를 정상 또는 빈 원장으로 위장하지 않는다", async ({ page, }) => { let releaseRequest = () => {}; const pendingResponse = new Promise((resolve) => { releaseRequest = resolve; }); await routeUser(page, "learner"); await routeReviewShell(page, "learner"); await page.route("**/api/practice/learners/me", async (route) => { await pendingResponse; await fulfillJson( route, { detail: "숙의 연습 API 연결이 준비되지 않았습니다." }, 503, ); }); const card = await openPractice(page, "learner"); await expect( card.getByRole("heading", { name: "숙의 연습 원장을 불러오는 중" }), ).toBeVisible(); await expect(card).toHaveAttribute("aria-busy", "true"); releaseRequest(); await expect( card.getByRole("heading", { name: "숙의 연습 원장을 표시할 수 없습니다", }), ).toBeVisible(); await expect(card).toContainText("준비되지 않았습니다"); await expect( card.getByRole("button", { name: "다시 불러오기" }), ).toBeVisible(); await expect( card.getByText("아직 연결된 숙의 연습이 없습니다"), ).toHaveCount(0); }); test("손상된 처방 식별자는 일반 연습으로 축약하지 않고 실행 인계를 보류한다", async ({ page, }) => { await routeUser(page, "learner"); await routeReviewShell(page, "learner"); const degraded = practiceReadModel(); degraded.prescriptions[0].prescription_payload.prescription_id = "invalid prescription id"; await page.route("**/api/practice/learners/me", (route) => fulfillJson(route, degraded), ); const card = await openPractice(page, "learner"); await expect(card.locator(".dp-launch-degraded")).toContainText( "실행 인계 식별자가 불완전", ); await expect( card.getByRole("link", { name: "이 처방으로 연습 시작" }), ).toHaveCount(0); }); test("모바일 다크모드에서 가로 넘침 없이 키보드와 스크린리더 이름을 유지한다", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); await page.addInitScript(() => { localStorage.setItem("vignette.theme", "dark"); }); await prepare(page, "learner"); const card = await openPractice(page, "learner"); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await expect(card.getByLabel("숙의 연습 방식 다섯 가지")).toBeVisible(); await expect( card.getByLabel("숙의 연습 방식 다섯 가지").locator("li"), ).toHaveCount(5); await expect(card.getByLabel("목표 행동 관찰")).toBeVisible(); await expect(card.getByLabel("직후 내담자 반응")).toBeVisible(); const launch = card.getByRole("link", { name: "이 처방으로 연습 시작" }); await expect(launch).toBeVisible(); const launchBox = await launch.boundingBox(); expect(launchBox?.height ?? 0).toBeGreaterThanOrEqual(44); const evidence = card.getByRole("button", { name: /05:04.*발화로 이동/ }); await evidence.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute( "aria-selected", "true", ); await page.getByRole("tab", { name: "피드백" }).click(); await expectNoHorizontalOverflow(page); await card.screenshot({ path: testInfo.outputPath("deliberate-practice-mobile-dark.png"), animations: "disabled", }); }); });