유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정

- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나
  웹 진입점이 없던 2결함
- a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩
  그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정
- uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정
- breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정
  — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로
- 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN),
  직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소,
  교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기
- 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json,
  SSOT 체커 PASS(59)
This commit is contained in:
Yun Chan 2026-08-18 11:47:58 +09:00
parent 4771b97c3a
commit cf899b7f15
37 changed files with 11989 additions and 67 deletions

View file

@ -0,0 +1,970 @@
/**
* 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(reviselockreveal , 01 ),
* self-directed-learning-loop.spec.ts( )
* ( CTA , 11 ,
* 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<void> {
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<string, string> = {
branch: "반응 분기 연습 열기",
constrained_response: "제약 응답 연습 열기",
voice_retry: "음성 재시도 연습 열기",
difficulty_ladder: "난도 단계 연습 열기",
};
await card.getByText("뒤에 대기 중인 연습 4개").click();
await card.getByRole("link", { name: queueLabel[mode] }).click();
}
const MODE_HEADING: Record<PracticeMode, string> = {
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<Record<string, unknown>> = [];
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<string, unknown>);
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();
});
});