G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
882
apps/web/e2e/deliberate-practice.spec.ts
Normal file
882
apps/web/e2e/deliberate-practice.spec.ts
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
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 }>;
|
||||
};
|
||||
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<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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue