유스케이스 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:
parent
4771b97c3a
commit
cf899b7f15
37 changed files with 11989 additions and 67 deletions
970
apps/web/e2e/uc-practice-g4-g5.spec.ts
Normal file
970
apps/web/e2e/uc-practice-g4-g5.spec.ts
Normal 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(revise→lock→reveal 전체 루프, 0→1 기록),
|
||||
* self-directed-learning-loop.spec.ts(홈→회기→리뷰→재연습 전체 여정)와
|
||||
* 겹치지 않도록 각 테스트는 다른 각도(개별 CTA 클릭 이동, 1→1 멱등 표시,
|
||||
* mastered 게이트, 잠금 후 폼 소멸, 재실행 버튼 쿼리 보존)를 다룬다.
|
||||
*/
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import type {
|
||||
DeliberatePracticeReadModel,
|
||||
PracticeEpisodeItem,
|
||||
PracticePrescriptionItem,
|
||||
} from "../src/pages/session-review/deliberatePracticeApi";
|
||||
import type {
|
||||
ActualTransferExecutionResponse,
|
||||
CalibrationTransferReadModelResponse,
|
||||
} from "../src/pages/session-review/calibrationTransferApi";
|
||||
import {
|
||||
filledReviewResponse,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
type PracticeMode = PracticePrescriptionItem["activity_mode"];
|
||||
|
||||
const LEARNER_ID = "61000000-0000-4000-8000-000000000001";
|
||||
const SRC_SESSION_ID = "61000000-0000-4000-8000-0000000000a1";
|
||||
const RETRY_SESSION_ID = "61000000-0000-4000-8000-0000000000a2";
|
||||
const TURN_UUIDS = [
|
||||
"62000000-0000-4000-8000-000000000001",
|
||||
"62000000-0000-4000-8000-000000000002",
|
||||
"62000000-0000-4000-8000-000000000003",
|
||||
"62000000-0000-4000-8000-000000000004",
|
||||
"62000000-0000-4000-8000-000000000005",
|
||||
"62000000-0000-4000-8000-000000000006",
|
||||
];
|
||||
const G4_MODES: PracticeMode[] = [
|
||||
"replay",
|
||||
"branch",
|
||||
"constrained_response",
|
||||
"voice_retry",
|
||||
"difficulty_ladder",
|
||||
];
|
||||
const G4_PRESCRIPTION_ID = (mode: PracticeMode) => `uc-g4-${mode}`;
|
||||
const G4_CRITERION_ID = "criterion.reflect-and-check";
|
||||
|
||||
const G5_HISTORY_ID = "63000000-0000-4000-8000-000000000210";
|
||||
const G5_REVISION_ID = "63000000-0000-4000-8000-000000000211";
|
||||
const G5_PRESCRIPTION_ID = "63000000-0000-4000-8000-000000000413";
|
||||
const G5_SUITE_ID = "63000000-0000-4000-8000-000000000510";
|
||||
const G5_TRIAL_ID = "63000000-0000-4000-8000-000000000515";
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G4 read model fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function g4Activity(mode: PracticeMode, index: number) {
|
||||
const common = {
|
||||
scenario_variant_id: `uc-variant-${mode}`,
|
||||
scenario_novelty: "familiar" as const,
|
||||
difficulty_level: index,
|
||||
};
|
||||
if (mode === "replay") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.replay.launch" as const,
|
||||
pause_at_evidence_ref: TURN_UUIDS[1],
|
||||
};
|
||||
}
|
||||
if (mode === "branch") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.branch.launch" as const,
|
||||
branch_options: ["감정 확인", "의미 확인"],
|
||||
client_responses_hidden: true as const,
|
||||
};
|
||||
}
|
||||
if (mode === "constrained_response") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.constrained-response.launch" as const,
|
||||
required_moves: ["정서 반영", "이해 확인"],
|
||||
max_words: 24,
|
||||
};
|
||||
}
|
||||
if (mode === "voice_retry") {
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.voice-retry.launch" as const,
|
||||
acoustic_focus: ["쉼", "말 속도"],
|
||||
max_seconds: 20,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
mode,
|
||||
launch_intent: "practice.difficulty-ladder.launch" as const,
|
||||
steps: [
|
||||
{
|
||||
level: 1,
|
||||
scenario_variant_id: "uc-ladder-familiar",
|
||||
scenario_novelty: "familiar" as const,
|
||||
variation: "내담자가 짧게 답하는 장면",
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
scenario_variant_id: "uc-ladder-unseen",
|
||||
scenario_novelty: "unseen_transfer" as const,
|
||||
variation: "내담자가 개입 의도를 되묻는 장면",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function g4Prescription(
|
||||
mode: PracticeMode,
|
||||
index: number,
|
||||
): PracticePrescriptionItem {
|
||||
const prescriptionId = G4_PRESCRIPTION_ID(mode);
|
||||
const competencyId = `competency.uc_${mode}`;
|
||||
const observable = `${mode} 장면에서 감정을 반영한 뒤 이해가 맞는지 확인한다.`;
|
||||
return {
|
||||
prescription_record_id: `64000000-0000-4000-8000-00000000000${index}`,
|
||||
prescription_key: prescriptionId,
|
||||
session_id: SRC_SESSION_ID,
|
||||
competency_id: competencyId,
|
||||
criterion_id: G4_CRITERION_ID,
|
||||
observable_behavior: observable,
|
||||
activity_mode: mode,
|
||||
scenario_variant_id: `uc-variant-${mode}`,
|
||||
scenario_novelty: "familiar",
|
||||
difficulty_level: index,
|
||||
prescription_payload: {
|
||||
schema_version: "vignette.practice-prescription.v1",
|
||||
event_name: "practice.prescribed",
|
||||
prescription_id: prescriptionId,
|
||||
coaching_card_id: `uc-g4-card-${mode}`,
|
||||
scene_id: `uc-scene-${mode}`,
|
||||
competency_id: competencyId,
|
||||
criterion_id: G4_CRITERION_ID,
|
||||
observable_behavior: observable,
|
||||
activity: g4Activity(mode, index),
|
||||
can_launch: true,
|
||||
evidence_refs: [
|
||||
{
|
||||
ref_id: TURN_UUIDS[1],
|
||||
scene_id: "uc-review-scene",
|
||||
turn_index: 2,
|
||||
actor: "learner",
|
||||
kind: "learner_behavior",
|
||||
},
|
||||
{
|
||||
ref_id: TURN_UUIDS[2],
|
||||
scene_id: "uc-review-scene",
|
||||
turn_index: 3,
|
||||
actor: "client",
|
||||
kind: "client_response",
|
||||
},
|
||||
],
|
||||
source_refs: ["synthetic:uc-g4-e2e:v1"],
|
||||
uncertainty: 0.3,
|
||||
counterevidence: [],
|
||||
},
|
||||
coach_claim: "같은 행동이 다른 장면에서도 유지되는지 확인합니다.",
|
||||
card_key: `uc-g4-card-${mode}`,
|
||||
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
|
||||
source_refs: ["synthetic:uc-g4-e2e:v1"],
|
||||
uncertainty: 0.3,
|
||||
counterevidence: [],
|
||||
created_at: "2026-08-10T10:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function g4Episode(
|
||||
sessionId: string,
|
||||
progress: PracticeEpisodeItem["progress"],
|
||||
): PracticeEpisodeItem {
|
||||
return {
|
||||
episode_submission_id: "65000000-0000-4000-8000-000000000001",
|
||||
episode_key: `uc-episode-${progress}`,
|
||||
session_id: sessionId,
|
||||
progress,
|
||||
mastery_allowed: progress === "mastered",
|
||||
mastery_blockers:
|
||||
progress === "mastered" ? [] : ["unseen_transfer_not_verified"],
|
||||
uncertainty: 0.25,
|
||||
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
|
||||
counterevidence: [],
|
||||
assessment_payload: {
|
||||
prescription_id: G4_PRESCRIPTION_ID("replay"),
|
||||
competency_id: "competency.uc_replay",
|
||||
} as unknown as PracticeEpisodeItem["assessment_payload"],
|
||||
created_at: "2026-08-10T10:05:00Z",
|
||||
attempts: [
|
||||
{
|
||||
attempt_record_id: "66000000-0000-4000-8000-000000000001",
|
||||
attempt_key: "uc-attempt-1",
|
||||
episode_submission_id: "65000000-0000-4000-8000-000000000001",
|
||||
sequence_no: 1,
|
||||
scenario_variant_id: "uc-variant-replay",
|
||||
scenario_novelty: "familiar",
|
||||
difficulty_level: 1,
|
||||
criterion_status: "observed",
|
||||
client_response: "engaged",
|
||||
outcome: "passed",
|
||||
utterance_template_id: "utterance-sha256:uc-fixture",
|
||||
learner_claimed_success: false,
|
||||
uncertainty: 0.25,
|
||||
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
|
||||
counterevidence: [],
|
||||
attempt_payload: {},
|
||||
created_at: "2026-08-10T10:05:00Z",
|
||||
corrections: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function g4ReadModel(options?: {
|
||||
episodes?: PracticeEpisodeItem[];
|
||||
familiarDemonstrations?: number;
|
||||
}): DeliberatePracticeReadModel {
|
||||
const familiar = options?.familiarDemonstrations ?? 0;
|
||||
return {
|
||||
learner_id: LEARNER_ID,
|
||||
clinical_claim_allowed: false,
|
||||
prescriptions: G4_MODES.map((mode, index) =>
|
||||
g4Prescription(mode, index + 1),
|
||||
),
|
||||
episodes: options?.episodes ?? [],
|
||||
competency_graph: {
|
||||
schema_version: "vignette.competency-graph.v1",
|
||||
definitions: G4_MODES.map((mode) => ({
|
||||
competency_id: `competency.uc_${mode}`,
|
||||
label_ko: mode === "replay" ? "공감적 반영 후 확인" : `${mode} 역량`,
|
||||
description: "하나의 행동을 장면에서 유지하는 연습 역량입니다.",
|
||||
prerequisite_ids: [],
|
||||
})),
|
||||
states: G4_MODES.map((mode, index) => ({
|
||||
competency_id: `competency.uc_${mode}`,
|
||||
band: mode === "replay" ? "fragile" : "developing",
|
||||
forgetting_risk: mode === "replay" ? 0.8 : 0.4 - index * 0.03,
|
||||
uncertainty: 0.3,
|
||||
attempt_count: mode === "replay" ? familiar : 0,
|
||||
familiar_demonstrations: mode === "replay" ? familiar : 0,
|
||||
unseen_transfer_demonstrations: 0,
|
||||
highest_familiar_difficulty: mode === "replay" ? 1 : 0,
|
||||
evidence_refs: [],
|
||||
counterevidence: [],
|
||||
})),
|
||||
},
|
||||
snapshot_id: "67000000-0000-4000-8000-000000000001",
|
||||
snapshot_no: 1,
|
||||
next_practice: {
|
||||
schema_version: "vignette.curriculum-decision.v1",
|
||||
selected_prescription_id: G4_PRESCRIPTION_ID("replay"),
|
||||
competency_id: "competency.uc_replay",
|
||||
competency_band: "fragile",
|
||||
forgetting_risk: 0.8,
|
||||
mode: "replay",
|
||||
selection_basis: [
|
||||
"weakest_available_band:fragile",
|
||||
"scenario_novelty:familiar",
|
||||
],
|
||||
deferred_prescription_ids: G4_MODES.slice(1).map(G4_PRESCRIPTION_ID),
|
||||
blocked_prescription_reasons: [],
|
||||
},
|
||||
decision_id: "68000000-0000-4000-8000-000000000001",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G5 read model fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function g5ReadModel(options?: {
|
||||
locked?: boolean;
|
||||
withSuite?: boolean;
|
||||
}): CalibrationTransferReadModelResponse {
|
||||
const model: CalibrationTransferReadModelResponse = {
|
||||
learner_id: LEARNER_ID,
|
||||
requested_view: "learner",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [
|
||||
{
|
||||
history_id: G5_HISTORY_ID,
|
||||
session_id: SRC_SESSION_ID,
|
||||
competency_id: "competency.empathic_reflection",
|
||||
practice_block_id: "uc-g5-block-001",
|
||||
scenario_variant_id: "uc-school-refusal",
|
||||
phrase_family_id: "uc-reflection-a",
|
||||
created_at: "2026-08-10T01:00:00Z",
|
||||
revisions: [
|
||||
{
|
||||
prediction_revision_id: G5_REVISION_ID,
|
||||
submission_id: "63000000-0000-4000-8000-000000000212",
|
||||
history_id: G5_HISTORY_ID,
|
||||
revision_no: 1,
|
||||
supersedes_prediction_revision_id: null,
|
||||
predicted_success_probability: 0.6,
|
||||
confidence: 0.55,
|
||||
recorded_sequence: 1,
|
||||
revision_reason: "반영 뒤 내담자 반응이 조금 열렸기 때문",
|
||||
source_kind: "learner_reported",
|
||||
perspective: "learner_self_report",
|
||||
instrument_id: "vignette.calibration-self-prediction",
|
||||
instrument_version: "1.0.0",
|
||||
evidence_turn_ids: [],
|
||||
created_at: "2026-08-10T01:00:00Z",
|
||||
},
|
||||
],
|
||||
lock: options?.locked
|
||||
? {
|
||||
lock_id: "63000000-0000-4000-8000-000000000310",
|
||||
submission_id: "63000000-0000-4000-8000-000000000311",
|
||||
history_id: G5_HISTORY_ID,
|
||||
prediction_revision_id: G5_REVISION_ID,
|
||||
locked_sequence: 2,
|
||||
created_at: "2026-08-10T01:02:00Z",
|
||||
}
|
||||
: null,
|
||||
external_observation: null,
|
||||
},
|
||||
],
|
||||
calibration_assessments: [],
|
||||
transfer_suites: options?.withSuite
|
||||
? [
|
||||
{
|
||||
transfer_suite_record_id: G5_SUITE_ID,
|
||||
submission_id: "63000000-0000-4000-8000-000000000511",
|
||||
suite_key: "uc-g5-suite-001",
|
||||
session_id: SRC_SESSION_ID,
|
||||
training_phrase_family_ids: ["uc-memorized-a"],
|
||||
model_run_id: "63000000-0000-4000-8000-000000000512",
|
||||
instrument_id: "vignette.unseen-transfer",
|
||||
instrument_version: "1.0.0",
|
||||
data_classification: "synthetic_educational",
|
||||
clinical_claim_allowed: false,
|
||||
created_at: "2026-08-10T01:05:00Z",
|
||||
trials: [
|
||||
{
|
||||
transfer_trial_record_id: G5_TRIAL_ID,
|
||||
transfer_suite_record_id: G5_SUITE_ID,
|
||||
trial_key: "uc-g5-transfer-001",
|
||||
competency_id: "competency.empathic_reflection",
|
||||
scenario_variant_id: "family-conflict-confrontational",
|
||||
scenario_novelty: "unseen_transfer",
|
||||
context_variant: "family-conflict",
|
||||
relationship_style: "confrontational",
|
||||
difficulty_level: 5,
|
||||
expression_variant: "direct-anger",
|
||||
synthetic_subgroup: "synthetic-family-b",
|
||||
scenario_family_id: "family-conflict",
|
||||
phrase_family_id: "uc-novel-b",
|
||||
status: "failed",
|
||||
uncertainty: 0.22,
|
||||
evidence_turn_ids: ["63000000-0000-4000-8000-000000000516"],
|
||||
counterevidence: ["client_rejected_reflection"],
|
||||
model_run_id: "63000000-0000-4000-8000-000000000512",
|
||||
instrument_id: "vignette.unseen-transfer",
|
||||
instrument_version: "1.0.0",
|
||||
created_at: "2026-08-10T01:06:00Z",
|
||||
},
|
||||
],
|
||||
assessments: [],
|
||||
drift_reports: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
teacher_reviews: [],
|
||||
actual_executions: [],
|
||||
actual_transfer_assessments: [],
|
||||
};
|
||||
return model;
|
||||
}
|
||||
|
||||
function g5EmptyReadModel(): CalibrationTransferReadModelResponse {
|
||||
return {
|
||||
learner_id: LEARNER_ID,
|
||||
requested_view: "learner",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [],
|
||||
calibration_assessments: [],
|
||||
transfer_suites: [],
|
||||
teacher_reviews: [],
|
||||
actual_executions: [],
|
||||
actual_transfer_assessments: [],
|
||||
};
|
||||
}
|
||||
|
||||
function actualTransferResponse(
|
||||
idempotentReplay: boolean,
|
||||
): ActualTransferExecutionResponse {
|
||||
return {
|
||||
execution: {
|
||||
execution_event_id: "63000000-0000-4000-8000-000000000611",
|
||||
original_transfer_trial_record_id: G5_TRIAL_ID,
|
||||
practice_session_id: RETRY_SESSION_ID,
|
||||
competency_id: "competency.empathic_reflection",
|
||||
scenario_variant_id: "family-conflict-confrontational",
|
||||
scenario_novelty: "unseen_transfer",
|
||||
variation: {
|
||||
context_variant: "family-conflict",
|
||||
relationship_style: "confrontational",
|
||||
difficulty_level: 5,
|
||||
expression_variant: "direct-anger",
|
||||
synthetic_subgroup: "synthetic-family-b",
|
||||
scenario_family_id: "family-conflict",
|
||||
phrase_family_id: "uc-novel-c",
|
||||
},
|
||||
training_phrase_collision: false,
|
||||
status: "passed",
|
||||
uncertainty: 0.18,
|
||||
evidence_turn_ids: ["63000000-0000-4000-8000-000000000612"],
|
||||
normalized_evaluator_labels: {
|
||||
technique_codes: ["reflection.feeling"],
|
||||
client_state_codes: ["engaged"],
|
||||
appropriateness: ["pos"],
|
||||
intent_deviation_dimensions: [],
|
||||
evaluator_error_count: 0,
|
||||
},
|
||||
counterevidence: [],
|
||||
model_run_id: "63000000-0000-4000-8000-000000000613",
|
||||
created_at: "2026-08-10T02:00:00Z",
|
||||
},
|
||||
assessment: {
|
||||
evidence_source: "actual_practice_execution",
|
||||
competency_id: "competency.empathic_reflection",
|
||||
execution_count: 1,
|
||||
independent_execution_count: 1,
|
||||
observed_execution_count: 1,
|
||||
success_rate: 1,
|
||||
success_interval: { method: "wilson_95", lower: 0.21, upper: 1 },
|
||||
coverage: {
|
||||
contexts: 1,
|
||||
relationship_styles: 1,
|
||||
difficulty_levels: 1,
|
||||
expression_variants: 1,
|
||||
scenario_families: 1,
|
||||
phrase_families: 1,
|
||||
},
|
||||
phrase_family_collision_count: 0,
|
||||
eligible: false,
|
||||
actual_transfer_status: "insufficient_evidence",
|
||||
blockers: ["actual_observed_executions_below_minimum:1/6"],
|
||||
source_execution_event_ids: ["63000000-0000-4000-8000-000000000611"],
|
||||
evidence_turn_ids: ["63000000-0000-4000-8000-000000000612"],
|
||||
},
|
||||
idempotent_replay: idempotentReplay,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routing helpers (mocked-auth 패턴: catch-all 먼저, 개별 fixture 나중)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function routeBase(page: Page, reviewSessionId: string) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
fulfillJson(route, { detail: "not part of this focused fixture" }, 404),
|
||||
);
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
fulfillJson(route, {
|
||||
user_id: LEARNER_ID,
|
||||
email: "learner@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "UC 학습자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["uc-e2e"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: "UC 학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
);
|
||||
const review = filledReviewResponse(reviewSessionId);
|
||||
review.turns = review.turns.map((turn, index) => ({
|
||||
...turn,
|
||||
turn_id: TURN_UUIDS[index],
|
||||
}));
|
||||
await page.route(`**/api/sessions/${reviewSessionId}/review`, (route) =>
|
||||
fulfillJson(route, review),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${reviewSessionId}/alliance-pulses`,
|
||||
(route) => fulfillJson(route, { items: [] }),
|
||||
);
|
||||
for (const suffix of ["outcome-trajectory", "ruptures"]) {
|
||||
await page.route(
|
||||
`**/api/sessions/${reviewSessionId}/${suffix}`,
|
||||
(route) => fulfillJson(route, { detail: "not found" }, 404),
|
||||
);
|
||||
}
|
||||
await routePrepostMeasures(page);
|
||||
}
|
||||
|
||||
async function routeG4(
|
||||
page: Page,
|
||||
model: () => DeliberatePracticeReadModel,
|
||||
) {
|
||||
await page.route("**/api/practice/learners/me", (route) =>
|
||||
fulfillJson(route, model()),
|
||||
);
|
||||
await page.route("**/api/calibration/learners/me", (route) =>
|
||||
fulfillJson(route, g5EmptyReadModel()),
|
||||
);
|
||||
}
|
||||
|
||||
async function routeG5(
|
||||
page: Page,
|
||||
model: () => CalibrationTransferReadModelResponse,
|
||||
) {
|
||||
await page.route("**/api/calibration/learners/me", (route) =>
|
||||
fulfillJson(route, model()),
|
||||
);
|
||||
}
|
||||
|
||||
async function openFeedback(page: Page, path: string) {
|
||||
await page.goto(path);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
}
|
||||
|
||||
function deliberateSearch(): string {
|
||||
return new URLSearchParams({
|
||||
launch: "deliberate",
|
||||
prescription: G4_PRESCRIPTION_ID("replay"),
|
||||
source_session: SRC_SESSION_ID,
|
||||
criterion: G4_CRITERION_ID,
|
||||
novelty: "familiar",
|
||||
mode: "replay",
|
||||
}).toString();
|
||||
}
|
||||
|
||||
function transferSearch(): string {
|
||||
return new URLSearchParams({
|
||||
launch: "transfer",
|
||||
prescription: G5_PRESCRIPTION_ID,
|
||||
suite: G5_SUITE_ID,
|
||||
trial: G5_TRIAL_ID,
|
||||
source_session: SRC_SESSION_ID,
|
||||
criterion: "competency.empathic_reflection",
|
||||
novelty: "unseen_transfer",
|
||||
mode: "counterevidence_forecast",
|
||||
}).toString();
|
||||
}
|
||||
|
||||
async function clickPracticeCta(
|
||||
page: Page,
|
||||
mode: PracticeMode,
|
||||
): Promise<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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue