vignette/apps/web/e2e/deliberate-practice.spec.ts
2026-08-09 19:38:04 +09:00

888 lines
31 KiB
TypeScript

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<Record<string, unknown>> = [];
await page.route(
"**/api/practice/oas-g4-practice-replay/attempts",
async (route) => {
submitted.push(
route.request().postDataJSON() as Record<string, unknown>,
);
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;
utterance_template_id?: string | null;
}>;
};
expect(episodeBody.episode_id).toMatch(/^oas-g4-episode-[a-f0-9-]+$/);
expect(episodeBody.attempts[0].attempt_id).toMatch(
/^oas-g4-attempt-[a-f0-9-]+$/,
);
expect(episodeBody.attempts[0].utterance_template_id).toBe(
"utterance-sha256:c06db0f06811c540ea7d985c7d4e3b880e978e33524128538d6f0ab7b23362aa",
);
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<string, unknown> | 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<string>();
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<void>((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",
});
});
});