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 산출물은 커밋에서 제외했다.
1215 lines
45 KiB
TypeScript
1215 lines
45 KiB
TypeScript
import path from "node:path";
|
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
import type { DeliberatePracticeReadModel } from "../src/pages/session-review/deliberatePracticeApi";
|
|
import { parsePracticeLaunchIntent } from "../src/lib/practiceLaunchIntent";
|
|
import {
|
|
filledReviewResponse,
|
|
routePrepostMeasures,
|
|
} from "./session-review-fixture";
|
|
import { expectNoHorizontalOverflow } from "./support";
|
|
|
|
const LEARNER_ID = "71000000-0000-4000-8000-000000000001";
|
|
const PRIMARY_SESSION_ID = "71000000-0000-4000-8000-000000000000";
|
|
const RETRY_SESSION_ID = "71000000-0000-4000-8000-000000000002";
|
|
const TURN_IDS = [
|
|
"71000000-0000-4000-8000-000000000011",
|
|
"71000000-0000-4000-8000-000000000012",
|
|
"71000000-0000-4000-8000-000000000013",
|
|
"71000000-0000-4000-8000-000000000014",
|
|
"71000000-0000-4000-8000-000000000015",
|
|
"71000000-0000-4000-8000-000000000016",
|
|
];
|
|
const PRESCRIPTION_ID = "oas-g4-practice-self-directed-replay";
|
|
const LEARNER_TEXT = "그 무기력함이 가장 크게 느껴지는 때를 천천히 살펴봐도 괜찮을까요?";
|
|
const CLIENT_REPLY = "아침에 눈을 뜰 때가 제일 막막해요.";
|
|
|
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function practiceReadModel(observed = false): DeliberatePracticeReadModel {
|
|
const model: DeliberatePracticeReadModel = {
|
|
learner_id: LEARNER_ID,
|
|
clinical_claim_allowed: false,
|
|
prescriptions: [
|
|
{
|
|
prescription_record_id: "71000000-0000-4000-8000-000000000021",
|
|
prescription_key: PRESCRIPTION_ID,
|
|
session_id: PRIMARY_SESSION_ID,
|
|
competency_id: "competency.empathic_reflection",
|
|
criterion_id: "criterion.reflect-and-check",
|
|
observable_behavior: "핵심 정서를 한 문장으로 반영한 뒤 이해가 맞는지 확인한다.",
|
|
activity_mode: "replay",
|
|
scenario_variant_id: "self-directed-reflection-replay",
|
|
scenario_novelty: "familiar",
|
|
difficulty_level: 1,
|
|
prescription_payload: {
|
|
schema_version: "vignette.practice-prescription.v1",
|
|
event_name: "practice.prescribed",
|
|
prescription_id: PRESCRIPTION_ID,
|
|
coaching_card_id: "oas-g4-card-self-directed-replay",
|
|
scene_id: "self-directed-review-scene",
|
|
competency_id: "competency.empathic_reflection",
|
|
criterion_id: "criterion.reflect-and-check",
|
|
observable_behavior:
|
|
"핵심 정서를 한 문장으로 반영한 뒤 이해가 맞는지 확인한다.",
|
|
activity: {
|
|
mode: "replay",
|
|
launch_intent: "practice.replay.launch",
|
|
scenario_variant_id: "self-directed-reflection-replay",
|
|
scenario_novelty: "familiar",
|
|
difficulty_level: 1,
|
|
pause_at_evidence_ref: TURN_IDS[0],
|
|
},
|
|
can_launch: true,
|
|
evidence_refs: [
|
|
{
|
|
ref_id: TURN_IDS[0],
|
|
scene_id: "self-directed-review-scene",
|
|
turn_index: 1,
|
|
actor: "learner",
|
|
kind: "learner_behavior",
|
|
},
|
|
{
|
|
ref_id: TURN_IDS[1],
|
|
scene_id: "self-directed-review-scene",
|
|
turn_index: 2,
|
|
actor: "client",
|
|
kind: "client_response",
|
|
},
|
|
],
|
|
source_refs: ["ui-fixture:self-directed-loop:v1"],
|
|
uncertainty: 0.35,
|
|
counterevidence: ["unseen_transfer_not_verified"],
|
|
},
|
|
coach_claim:
|
|
"정서를 알아차렸지만 이해가 맞는지 확인하기 전에 다음 질문으로 이동했습니다.",
|
|
card_key: "oas-g4-card-self-directed-replay",
|
|
evidence_turn_ids: [TURN_IDS[0], TURN_IDS[1]],
|
|
source_refs: ["ui-fixture:self-directed-loop:v1"],
|
|
uncertainty: 0.35,
|
|
counterevidence: ["unseen_transfer_not_verified"],
|
|
created_at: "2026-08-07T03:00:00Z",
|
|
},
|
|
],
|
|
episodes: observed
|
|
? [
|
|
{
|
|
episode_submission_id:
|
|
"71000000-0000-4000-8000-000000000041",
|
|
episode_key: "runtime-practice-retry-session",
|
|
session_id: RETRY_SESSION_ID,
|
|
progress: "transfer_pending",
|
|
mastery_allowed: false,
|
|
mastery_blockers: ["unseen_transfer_not_verified"],
|
|
uncertainty: 0.25,
|
|
evidence_turn_ids: [TURN_IDS[2], TURN_IDS[3]],
|
|
counterevidence: ["unseen_transfer_not_verified"],
|
|
assessment_payload: {
|
|
prescription_id: PRESCRIPTION_ID,
|
|
competency_id: "competency.empathic_reflection",
|
|
practice_session_source: "independent_observer",
|
|
} as DeliberatePracticeReadModel["episodes"][number]["assessment_payload"],
|
|
created_at: "2026-08-07T03:20:00Z",
|
|
attempts: [
|
|
{
|
|
attempt_record_id:
|
|
"71000000-0000-4000-8000-000000000042",
|
|
attempt_key: "runtime-practice-retry-session-attempt-1",
|
|
episode_submission_id:
|
|
"71000000-0000-4000-8000-000000000041",
|
|
sequence_no: 1,
|
|
scenario_variant_id: "runtime-session-retry",
|
|
scenario_novelty: "familiar",
|
|
difficulty_level: 1,
|
|
criterion_status: "observed",
|
|
client_response: "engaged",
|
|
outcome: "passed",
|
|
utterance_template_id: "utterance-sha256:runtime-fixture",
|
|
learner_claimed_success: false,
|
|
uncertainty: 0.25,
|
|
evidence_turn_ids: [TURN_IDS[2], TURN_IDS[3]],
|
|
counterevidence: [],
|
|
attempt_payload: {
|
|
observation_source: "independent_observer",
|
|
},
|
|
created_at: "2026-08-07T03:20:00Z",
|
|
corrections: [],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
: [],
|
|
competency_graph: {
|
|
schema_version: "vignette.competency-graph.v1",
|
|
definitions: [
|
|
{
|
|
competency_id: "competency.empathic_reflection",
|
|
label_ko: "공감적 반영",
|
|
description: "정서를 반영하고 이해가 맞는지 확인하는 역량입니다.",
|
|
prerequisite_ids: [],
|
|
},
|
|
],
|
|
states: [
|
|
{
|
|
competency_id: "competency.empathic_reflection",
|
|
band: observed ? "consistent_local" : "unassessed",
|
|
forgetting_risk: 0.7,
|
|
uncertainty: 0.35,
|
|
attempt_count: observed ? 1 : 0,
|
|
familiar_demonstrations: observed ? 1 : 0,
|
|
unseen_transfer_demonstrations: 0,
|
|
highest_familiar_difficulty: 0,
|
|
evidence_refs: [],
|
|
counterevidence: ["unseen_transfer_not_verified"],
|
|
},
|
|
],
|
|
},
|
|
snapshot_id: observed
|
|
? "71000000-0000-4000-8000-000000000043"
|
|
: "71000000-0000-4000-8000-000000000022",
|
|
snapshot_no: observed ? 2 : 1,
|
|
next_practice: {
|
|
schema_version: "vignette.curriculum-decision.v1",
|
|
selected_prescription_id: PRESCRIPTION_ID,
|
|
competency_id: "competency.empathic_reflection",
|
|
competency_band: "unassessed",
|
|
forgetting_risk: 0.7,
|
|
mode: "replay",
|
|
selection_basis: [
|
|
"weakest_available_band:unassessed",
|
|
"scenario_novelty:familiar",
|
|
],
|
|
deferred_prescription_ids: [],
|
|
blocked_prescription_reasons: [],
|
|
},
|
|
decision_id: "71000000-0000-4000-8000-000000000023",
|
|
};
|
|
return model;
|
|
}
|
|
|
|
function sessionSummary(ended: boolean) {
|
|
return {
|
|
session_id: PRIMARY_SESSION_ID,
|
|
persona_code: "P1",
|
|
persona_name: "민서",
|
|
session_no: 1,
|
|
status: ended ? "ended" : "active",
|
|
stage: ended ? "탐색" : "라포",
|
|
started_at: "2026-08-07T02:00:00Z",
|
|
ended_at: ended ? "2026-08-07T02:10:00Z" : null,
|
|
review_ready: ended,
|
|
turn_count: ended ? 2 : 0,
|
|
learner_turn_count: ended ? 1 : 0,
|
|
client_turn_count: ended ? 1 : 0,
|
|
archived: false,
|
|
archived_at: null,
|
|
};
|
|
}
|
|
|
|
function activeSessionDetail(sessionId: string, ended: boolean) {
|
|
return {
|
|
session_id: sessionId,
|
|
case_id: "self-directed-case-001",
|
|
persona_code: "P1",
|
|
persona_name: "민서",
|
|
session_no: sessionId === PRIMARY_SESSION_ID ? 1 : 2,
|
|
status: ended ? "ended" : "active",
|
|
stage: ended ? "탐색" : "라포",
|
|
theory_mode: "humanistic",
|
|
effective_openness: ended ? 0.42 : 0.21,
|
|
started_at: "2026-08-07T02:00:00Z",
|
|
ended_at: ended ? "2026-08-07T02:10:00Z" : null,
|
|
review_ready: ended,
|
|
goal_stages: ["라포", "탐색", "개입"],
|
|
turns: ended
|
|
? [
|
|
{
|
|
turn_id: TURN_IDS[0],
|
|
turn_seq: 1,
|
|
speaker: "counselor",
|
|
stage: "라포",
|
|
text: LEARNER_TEXT,
|
|
text_masked: LEARNER_TEXT,
|
|
},
|
|
{
|
|
turn_id: TURN_IDS[1],
|
|
turn_seq: 2,
|
|
speaker: "client",
|
|
stage: "탐색",
|
|
text: CLIENT_REPLY,
|
|
text_masked: CLIENT_REPLY,
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
async function routeSelfDirectedUiFixture(page: Page) {
|
|
let primaryEnded = false;
|
|
let retryEnded = false;
|
|
let practiceObserved = false;
|
|
let practiceReadCount = 0;
|
|
let runtimeObservationCount = 0;
|
|
let startCount = 0;
|
|
const startRequests: Array<Record<string, unknown>> = [];
|
|
const runtimeObservationRequests: Array<{
|
|
method: string;
|
|
url: string;
|
|
body: string | null;
|
|
}> = [];
|
|
|
|
await page.addInitScript(() => {
|
|
const audit = { getUserMedia: 0, enumerateDevices: 0 };
|
|
Object.defineProperty(globalThis, "__selfDirectedMediaAudit", {
|
|
configurable: true,
|
|
value: audit,
|
|
});
|
|
const denied = async (kind: keyof typeof audit) => {
|
|
audit[kind] += 1;
|
|
throw new DOMException(
|
|
"물리 미디어 접근은 자기주도 UI fixture에서 금지됩니다.",
|
|
"NotAllowedError",
|
|
);
|
|
};
|
|
if (!navigator.mediaDevices) {
|
|
Object.defineProperty(navigator, "mediaDevices", {
|
|
configurable: true,
|
|
value: {},
|
|
});
|
|
}
|
|
Object.defineProperty(navigator.mediaDevices, "getUserMedia", {
|
|
configurable: true,
|
|
value: () => denied("getUserMedia"),
|
|
});
|
|
Object.defineProperty(navigator.mediaDevices, "enumerateDevices", {
|
|
configurable: true,
|
|
value: () => denied("enumerateDevices"),
|
|
});
|
|
});
|
|
|
|
// Fail closed: every unowned API route is a fixture 404, never a real backend call.
|
|
await page.route("**/api/**", (route) =>
|
|
fulfillJson(route, { detail: "not part of self-directed UI fixture" }, 404),
|
|
);
|
|
await page.route("**/api/auth/me", (route) =>
|
|
fulfillJson(route, {
|
|
user_id: LEARNER_ID,
|
|
email: "self-directed-ui-fixture@hs.ac.kr",
|
|
display_name: "자기주도 UI Fixture",
|
|
role: "learner",
|
|
admin_access: false,
|
|
super_admin: false,
|
|
account_status: "approved",
|
|
approval_required: false,
|
|
cohort_ids: ["ui-fixture"],
|
|
consent_at: 1,
|
|
onboarding_completed_at: 1,
|
|
nickname: "자기주도 학습자",
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
}),
|
|
);
|
|
await page.route("**/api/personas", (route) =>
|
|
fulfillJson(route, [
|
|
{
|
|
code: "P1",
|
|
display_name: "민서(가명) · 고2 · 무기력과 학교 적응",
|
|
difficulty: "hard",
|
|
theory_target: ["humanistic", "cbt"],
|
|
demographics: { age_band: "10대" },
|
|
presenting_summary: "무기력과 학교 적응을 다루는 상담 연습",
|
|
voice_preset: "soft-young-fem",
|
|
source: "database",
|
|
degraded: false,
|
|
},
|
|
]),
|
|
);
|
|
await page.route("**/api/sessions/dashboard", (route) =>
|
|
fulfillJson(route, {
|
|
source: "ui_fixture",
|
|
message: "로그인 없는 브라우저 UI 계약 fixture",
|
|
overview: {
|
|
total_sessions: primaryEnded ? 1 : 0,
|
|
active_sessions: 0,
|
|
completed_sessions: primaryEnded ? 1 : 0,
|
|
review_ready_sessions: primaryEnded ? 1 : 0,
|
|
archived_sessions: 0,
|
|
learner_turns: primaryEnded ? 1 : 0,
|
|
client_turns: primaryEnded ? 1 : 0,
|
|
last_practiced_at: primaryEnded ? "2026-08-07T02:10:00Z" : null,
|
|
},
|
|
growth: {
|
|
evaluated_sessions: primaryEnded ? 1 : 0,
|
|
trend: "insufficient",
|
|
avg_rapport: null,
|
|
avg_score: null,
|
|
first_score: null,
|
|
latest_score: null,
|
|
score_delta: null,
|
|
points: [],
|
|
top_techniques: [],
|
|
},
|
|
recent_feedback: [],
|
|
persona_progress: [],
|
|
achievements: [],
|
|
}),
|
|
);
|
|
await page.route("**/api/sessions", async (route) => {
|
|
if (route.request().method() === "POST") {
|
|
startCount += 1;
|
|
startRequests.push(
|
|
route.request().postDataJSON() as Record<string, unknown>,
|
|
);
|
|
await fulfillJson(
|
|
route,
|
|
{
|
|
session_id:
|
|
startCount === 1 ? PRIMARY_SESSION_ID : RETRY_SESSION_ID,
|
|
case_id: "self-directed-case-001",
|
|
session_no: startCount,
|
|
stage: "라포",
|
|
effective_openness: 0.21,
|
|
recall_summary: null,
|
|
degraded: false,
|
|
goal_stages: ["라포", "탐색", "개입"],
|
|
},
|
|
201,
|
|
);
|
|
return;
|
|
}
|
|
await fulfillJson(route, {
|
|
source: "ui_fixture",
|
|
sessions: primaryEnded ? [sessionSummary(true)] : [],
|
|
});
|
|
});
|
|
await page.route(/\/api\/sessions\/(P1|[0-9a-f-]{36})$/, (route) => {
|
|
const sessionId = new URL(route.request().url()).pathname.split("/").at(-1)!;
|
|
if (sessionId === "P1") {
|
|
return fulfillJson(route, { detail: "persona route, not a session UUID" }, 404);
|
|
}
|
|
return fulfillJson(
|
|
route,
|
|
activeSessionDetail(
|
|
sessionId,
|
|
(sessionId === PRIMARY_SESSION_ID && primaryEnded) ||
|
|
(sessionId === RETRY_SESSION_ID && retryEnded),
|
|
),
|
|
);
|
|
});
|
|
for (const sessionId of [PRIMARY_SESSION_ID, RETRY_SESSION_ID]) {
|
|
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, (route) =>
|
|
fulfillJson(route, {
|
|
items: [
|
|
{
|
|
pulse_id:
|
|
sessionId === PRIMARY_SESSION_ID
|
|
? "71000000-0000-4000-8000-000000000031"
|
|
: "71000000-0000-4000-8000-000000000032",
|
|
checkpoint: "pre",
|
|
status: "ready",
|
|
learner_locked_at: "2026-08-07T02:00:00Z",
|
|
revealed_at: "2026-08-07T02:00:01Z",
|
|
error_code: null,
|
|
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
|
measurements: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
await page.route(`**/api/sessions/${sessionId}/live-coach`, (route) =>
|
|
fulfillJson(route, {
|
|
source: "ui_fixture",
|
|
quota: { remaining: 3, max: 3 },
|
|
credit_events: [],
|
|
events: [],
|
|
}),
|
|
);
|
|
}
|
|
await page.route(`**/api/sessions/${PRIMARY_SESSION_ID}/stream`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: [
|
|
"event: token",
|
|
`data: ${CLIENT_REPLY}`,
|
|
"",
|
|
"event: done",
|
|
`data: ${JSON.stringify({
|
|
session_id: PRIMARY_SESSION_ID,
|
|
stage: "탐색",
|
|
effective_openness: 0.42,
|
|
turn_seq: 1,
|
|
safety_flagged: false,
|
|
})}`,
|
|
"",
|
|
].join("\n"),
|
|
}),
|
|
);
|
|
await page.route(`**/api/sessions/${RETRY_SESSION_ID}/stream`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: [
|
|
"event: token",
|
|
`data: ${CLIENT_REPLY}`,
|
|
"",
|
|
"event: done",
|
|
`data: ${JSON.stringify({
|
|
session_id: RETRY_SESSION_ID,
|
|
stage: "탐색",
|
|
effective_openness: 0.48,
|
|
turn_seq: 1,
|
|
safety_flagged: false,
|
|
})}`,
|
|
"",
|
|
].join("\n"),
|
|
}),
|
|
);
|
|
await page.route(`**/api/sessions/${PRIMARY_SESSION_ID}/end`, (route) => {
|
|
primaryEnded = true;
|
|
return fulfillJson(route, {
|
|
session_id: PRIMARY_SESSION_ID,
|
|
session_no: 1,
|
|
digest_pending: true,
|
|
end_state: {
|
|
stage: "탐색",
|
|
turn_seq: 1,
|
|
effective_openness: 0.42,
|
|
},
|
|
});
|
|
});
|
|
await page.route(`**/api/sessions/${PRIMARY_SESSION_ID}/review`, (route) => {
|
|
const review = filledReviewResponse(PRIMARY_SESSION_ID);
|
|
review.turns = review.turns.map((turn, index) => ({
|
|
...turn,
|
|
turn_id: TURN_IDS[index],
|
|
}));
|
|
review.summary = "평가 AI가 저장된 축어록을 분석했습니다.";
|
|
review.clientFeedback = CLIENT_REPLY;
|
|
return fulfillJson(route, review);
|
|
});
|
|
await page.route(`**/api/sessions/${RETRY_SESSION_ID}/end`, (route) => {
|
|
retryEnded = true;
|
|
return fulfillJson(route, {
|
|
session_id: RETRY_SESSION_ID,
|
|
session_no: 2,
|
|
digest_pending: true,
|
|
end_state: {
|
|
stage: "탐색",
|
|
turn_seq: 1,
|
|
effective_openness: 0.48,
|
|
},
|
|
});
|
|
});
|
|
await page.route(`**/api/sessions/${RETRY_SESSION_ID}/review`, (route) => {
|
|
const review = filledReviewResponse(RETRY_SESSION_ID);
|
|
review.turns = review.turns.map((turn, index) => ({
|
|
...turn,
|
|
turn_id: TURN_IDS[index + 2],
|
|
}));
|
|
review.summary = "재연습 회기의 저장된 축어록 평가가 준비됐습니다.";
|
|
review.clientFeedback = CLIENT_REPLY;
|
|
return fulfillJson(route, review);
|
|
});
|
|
await page.route("**/api/practice/learners/me", (route) => {
|
|
practiceReadCount += 1;
|
|
return fulfillJson(route, practiceReadModel(practiceObserved));
|
|
});
|
|
await page.route(
|
|
`**/api/practice/${PRESCRIPTION_ID}/attempts/from-session/${RETRY_SESSION_ID}`,
|
|
(route) => {
|
|
runtimeObservationCount += 1;
|
|
runtimeObservationRequests.push({
|
|
method: route.request().method(),
|
|
url: route.request().url(),
|
|
body: route.request().postData(),
|
|
});
|
|
if (runtimeObservationCount === 1) {
|
|
return fulfillJson(
|
|
route,
|
|
{
|
|
detail:
|
|
"session evaluation is not ready for runtime practice observation",
|
|
},
|
|
422,
|
|
);
|
|
}
|
|
practiceObserved = true;
|
|
return fulfillJson(
|
|
route,
|
|
{
|
|
submission_id: "71000000-0000-4000-8000-000000000041",
|
|
progress: "transfer_pending",
|
|
mastery_allowed: false,
|
|
snapshot_id: "71000000-0000-4000-8000-000000000043",
|
|
decision_id: "71000000-0000-4000-8000-000000000044",
|
|
next_prescription_id: PRESCRIPTION_ID,
|
|
idempotent_replay: runtimeObservationCount > 2,
|
|
},
|
|
201,
|
|
);
|
|
},
|
|
);
|
|
await page.route("**/api/voice/health", (route) =>
|
|
fulfillJson(route, { available: false, reason: "UI fixture: physical mic disabled" }),
|
|
);
|
|
await routePrepostMeasures(page);
|
|
|
|
return {
|
|
startRequests,
|
|
runtimeObservationRequests,
|
|
get primaryEnded() {
|
|
return primaryEnded;
|
|
},
|
|
get retryEnded() {
|
|
return retryEnded;
|
|
},
|
|
get practiceReadCount() {
|
|
return practiceReadCount;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function expectInsideInitialViewport(
|
|
page: Page,
|
|
selector: string,
|
|
label: string,
|
|
) {
|
|
const result = await page.locator(selector).evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
top: rect.top,
|
|
right: rect.right,
|
|
bottom: rect.bottom,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
};
|
|
});
|
|
expect(result.top, `${label} top`).toBeGreaterThanOrEqual(0);
|
|
expect(result.left, `${label} left`).toBeGreaterThanOrEqual(0);
|
|
expect(result.right, `${label} right`).toBeLessThanOrEqual(
|
|
result.viewportWidth,
|
|
);
|
|
expect(result.bottom, `${label} bottom`).toBeLessThanOrEqual(
|
|
result.viewportHeight,
|
|
);
|
|
expect(result.height, `${label} touch target height`).toBeGreaterThanOrEqual(
|
|
44,
|
|
);
|
|
}
|
|
|
|
async function expectMinimumHitTargets(
|
|
page: Page,
|
|
selector: string,
|
|
label: string,
|
|
) {
|
|
const boxes = await page.locator(selector).evaluateAll((elements) =>
|
|
elements
|
|
.filter((element) => {
|
|
const style = getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0;
|
|
})
|
|
.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
text: element.textContent?.trim() ?? "",
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
}),
|
|
);
|
|
expect(boxes.length, `${label} count`).toBeGreaterThan(0);
|
|
for (const box of boxes) {
|
|
expect(box.width, `${label} ${box.text} width`).toBeGreaterThanOrEqual(44);
|
|
expect(box.height, `${label} ${box.text} height`).toBeGreaterThanOrEqual(44);
|
|
}
|
|
}
|
|
|
|
async function expectAtMainScrollStart(
|
|
page: Page,
|
|
selector: string,
|
|
label: string,
|
|
) {
|
|
const main = page.locator(".vg-main");
|
|
await main.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
});
|
|
const result = await page.locator(selector).evaluate((element) => {
|
|
const mainElement = document.querySelector<HTMLElement>(".vg-main");
|
|
if (!mainElement) throw new Error(".vg-main not found");
|
|
const mainRect = mainElement.getBoundingClientRect();
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
scrollTop: mainElement.scrollTop,
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
mainTop: mainRect.top,
|
|
mainBottom: mainRect.bottom,
|
|
height: rect.height,
|
|
};
|
|
});
|
|
expect(result.scrollTop, `${label} main scroll start`).toBe(0);
|
|
expect(result.top, `${label} top`).toBeGreaterThanOrEqual(result.mainTop);
|
|
expect(result.bottom, `${label} bottom`).toBeLessThanOrEqual(result.mainBottom);
|
|
expect(result.height, `${label} touch target`).toBeGreaterThanOrEqual(44);
|
|
}
|
|
|
|
async function expectLearnerMobileNavIntegrity(page: Page) {
|
|
const result = await page.locator(".vg-nav").evaluate((nav) => {
|
|
const navRect = nav.getBoundingClientRect();
|
|
return Array.from(nav.querySelectorAll<HTMLElement>(".vg-nav__item")).map(
|
|
(item) => {
|
|
const rect = item.getBoundingClientRect();
|
|
return {
|
|
label: item.textContent?.trim() ?? "",
|
|
left: rect.left,
|
|
right: rect.right,
|
|
height: rect.height,
|
|
navLeft: navRect.left,
|
|
navRight: navRect.right,
|
|
};
|
|
},
|
|
);
|
|
});
|
|
expect(result.map((item) => item.label)).toEqual([
|
|
"대시보드",
|
|
"학습",
|
|
"기록",
|
|
"설정",
|
|
]);
|
|
for (const item of result) {
|
|
expect(item.left, `${item.label} left`).toBeGreaterThanOrEqual(item.navLeft);
|
|
expect(item.right, `${item.label} right`).toBeLessThanOrEqual(item.navRight);
|
|
expect(item.height, `${item.label} touch target height`).toBeGreaterThanOrEqual(
|
|
44,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function expectNoPhysicalMediaAccess(page: Page) {
|
|
const audit = await page.evaluate(() =>
|
|
(
|
|
globalThis as typeof globalThis & {
|
|
__selfDirectedMediaAudit?: {
|
|
getUserMedia: number;
|
|
enumerateDevices: number;
|
|
};
|
|
}
|
|
).__selfDirectedMediaAudit,
|
|
);
|
|
expect(audit).toEqual({ getUserMedia: 0, enumerateDevices: 0 });
|
|
}
|
|
|
|
async function expectReadableButtonContrast(page: Page, name: string) {
|
|
const ratio = await page
|
|
.getByRole("button", { name })
|
|
.evaluate((element) => {
|
|
const parse = (value: string): [number, number, number] => {
|
|
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [];
|
|
return [channels[0] ?? 0, channels[1] ?? 0, channels[2] ?? 0];
|
|
};
|
|
const luminance = ([red, green, blue]: [number, number, number]) => {
|
|
const linear = [red, green, blue].map((channel) => {
|
|
const normalized = channel / 255;
|
|
return normalized <= 0.04045
|
|
? normalized / 12.92
|
|
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
|
};
|
|
const style = getComputedStyle(element);
|
|
const foreground = luminance(parse(style.color));
|
|
const background = luminance(parse(style.backgroundColor));
|
|
return (
|
|
(Math.max(foreground, background) + 0.05) /
|
|
(Math.min(foreground, background) + 0.05)
|
|
);
|
|
});
|
|
expect(ratio, `${name} text contrast`).toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
|
|
async function expectCoachNudgeContrast(page: Page) {
|
|
const ratios = await page.locator(".sx-coach-nudge").evaluate((element) => {
|
|
const parse = (value: string): [number, number, number] => {
|
|
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [];
|
|
return [channels[0] ?? 0, channels[1] ?? 0, channels[2] ?? 0];
|
|
};
|
|
const luminance = ([red, green, blue]: [number, number, number]) => {
|
|
const linear = [red, green, blue].map((channel) => {
|
|
const normalized = channel / 255;
|
|
return normalized <= 0.04045
|
|
? normalized / 12.92
|
|
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
|
};
|
|
const ratio = (foreground: string, background: string) => {
|
|
const foregroundLuminance = luminance(parse(foreground));
|
|
const backgroundLuminance = luminance(parse(background));
|
|
return (
|
|
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
|
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
|
);
|
|
};
|
|
const buttonStyle = getComputedStyle(element);
|
|
const emphasis = element.querySelector<HTMLElement>("em");
|
|
return {
|
|
body: ratio(buttonStyle.color, buttonStyle.backgroundColor),
|
|
action: ratio(
|
|
emphasis ? getComputedStyle(emphasis).color : buttonStyle.color,
|
|
buttonStyle.backgroundColor,
|
|
),
|
|
};
|
|
});
|
|
expect(ratios.body, "코칭 토스트 본문 대비").toBeGreaterThanOrEqual(4.5);
|
|
expect(ratios.action, "코칭 열기 대비").toBeGreaterThanOrEqual(4.5);
|
|
}
|
|
|
|
async function expectMobileActiveVisualIntegrity(page: Page) {
|
|
const clientReply = page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY });
|
|
await clientReply.scrollIntoViewIfNeeded();
|
|
const result = await page.evaluate(() => {
|
|
const visibleRect = (selector: string) => {
|
|
const element = document.querySelector<HTMLElement>(selector);
|
|
if (!element) return null;
|
|
const style = getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
if (
|
|
style.display === "none" ||
|
|
style.visibility === "hidden" ||
|
|
rect.width <= 0 ||
|
|
rect.height <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
return rect;
|
|
};
|
|
const intersects = (a: DOMRect, b: DOMRect) =>
|
|
Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 &&
|
|
Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
|
|
const contained = (outer: DOMRect, inner: DOMRect) =>
|
|
inner.left >= outer.left - 1 &&
|
|
inner.right <= outer.right + 1 &&
|
|
inner.top >= outer.top - 1 &&
|
|
inner.bottom <= outer.bottom + 1;
|
|
|
|
const stage = visibleRect(".sx-stage");
|
|
const avatar = visibleRect(".sx-orb-wrap");
|
|
const client = visibleRect(".sx-stage__client");
|
|
const now = visibleRect(".sx-stage__now");
|
|
const transcript = visibleRect(".sx-transcript");
|
|
const scroll = visibleRect(".sx-transcript__scroll");
|
|
const compose = visibleRect(".sx-compose");
|
|
const replies = Array.from(
|
|
document.querySelectorAll<HTMLElement>(".sx-transcript__scroll .sx-utt"),
|
|
).filter((element) => getComputedStyle(element).display !== "none");
|
|
const latestReply = replies.at(-1)?.getBoundingClientRect() ?? null;
|
|
const overlaps: string[] = [];
|
|
if (avatar && client && intersects(avatar, client)) overlaps.push("avatar/client");
|
|
if (client && now && intersects(client, now)) overlaps.push("client/status");
|
|
if (scroll && compose && intersects(scroll, compose)) overlaps.push("transcript/compose");
|
|
|
|
return {
|
|
viewport: `${innerWidth}x${innerHeight}`,
|
|
overlaps,
|
|
stageContainsAvatar: Boolean(stage && avatar && contained(stage, avatar)),
|
|
stageContainsClient: Boolean(stage && client && contained(stage, client)),
|
|
statusHidden: now == null,
|
|
transcriptContained: Boolean(
|
|
transcript && scroll && contained(transcript, scroll),
|
|
),
|
|
composeContained: Boolean(
|
|
transcript && compose && contained(transcript, compose),
|
|
),
|
|
latestReplyVisible: Boolean(
|
|
scroll && latestReply && contained(scroll, latestReply),
|
|
),
|
|
};
|
|
});
|
|
|
|
expect(result.overlaps, result.viewport).toEqual([]);
|
|
expect(result.stageContainsAvatar, result.viewport).toBe(true);
|
|
expect(result.stageContainsClient, result.viewport).toBe(true);
|
|
expect(result.statusHidden, result.viewport).toBe(true);
|
|
expect(result.transcriptContained, result.viewport).toBe(true);
|
|
expect(result.composeContained, result.viewport).toBe(true);
|
|
expect(result.latestReplyVisible, result.viewport).toBe(true);
|
|
}
|
|
|
|
async function capture(page: Page, projectName: string, stage: string) {
|
|
if (process.env.E2E_SKIP_EVIDENCE_CAPTURE === "1") return;
|
|
const safeProject = projectName.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
|
|
const evidenceDirectory = process.env.E2E_EVIDENCE_DIR
|
|
? path.resolve(process.env.E2E_EVIDENCE_DIR)
|
|
: path.resolve(process.cwd(), "../../docs/ops/evidence");
|
|
await page.screenshot({
|
|
path: path.resolve(
|
|
evidenceDirectory,
|
|
`self-directed-loop-${stage}-${safeProject}-2026-08-07.png`,
|
|
),
|
|
fullPage: true,
|
|
animations: "disabled",
|
|
});
|
|
}
|
|
|
|
test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fixture", () => {
|
|
test("홈 추천과 목표 선택부터 회기·복기·처방 재연습 시작까지 한 흐름으로 보존한다", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await routeSelfDirectedUiFixture(page);
|
|
|
|
await page.goto("/learn");
|
|
await expect(
|
|
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
|
).toBeVisible();
|
|
await expect(page.getByLabel("AI 코치")).toContainText(
|
|
"첫 회기를 시작할 차례입니다.",
|
|
);
|
|
await expect(page.getByText("연습 목표를 좁힙니다.")).toBeVisible();
|
|
await expect(page.getByText("난도 확장 검토")).toBeVisible();
|
|
const dashboardTablist = page.getByRole("tablist", {
|
|
name: "대시보드 영역 선택",
|
|
});
|
|
const dashboardTabs = dashboardTablist.getByRole("tab");
|
|
await expect(dashboardTabs).toHaveCount(3);
|
|
for (const tabName of ["오늘의 회기", "기록 · 리뷰", "성장 지표"]) {
|
|
const tab = dashboardTablist.getByRole("tab", { name: tabName });
|
|
const panelId = await tab.getAttribute("aria-controls");
|
|
expect(panelId).toBeTruthy();
|
|
await expect(page.locator(`#${panelId}`)).toHaveAttribute("role", "tabpanel");
|
|
}
|
|
const todayTab = dashboardTablist.getByRole("tab", { name: "오늘의 회기" });
|
|
const recordsTab = dashboardTablist.getByRole("tab", { name: "기록 · 리뷰" });
|
|
const growthTab = dashboardTablist.getByRole("tab", { name: "성장 지표" });
|
|
await todayTab.focus();
|
|
await page.keyboard.press("End");
|
|
await expect(growthTab).toBeFocused();
|
|
await expect(growthTab).toHaveAttribute("aria-selected", "true");
|
|
await expect(page.locator("#lh-panel-growth")).toBeVisible();
|
|
await page.keyboard.press("Home");
|
|
await expect(todayTab).toBeFocused();
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(recordsTab).toBeFocused();
|
|
await expect(recordsTab).toHaveAttribute("aria-selected", "true");
|
|
await page.keyboard.press("ArrowLeft");
|
|
await expect(todayTab).toBeFocused();
|
|
await expect(todayTab).toHaveAttribute("tabindex", "0");
|
|
await expect(recordsTab).toHaveAttribute("tabindex", "-1");
|
|
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "학습 홈 탭");
|
|
const primaryAction = page.locator("[data-learner-primary-action]");
|
|
await expect(primaryAction).toHaveCount(1);
|
|
await expect(primaryAction).toHaveAccessibleName("학습 대상 선택");
|
|
await expectInsideInitialViewport(
|
|
page,
|
|
"[data-learner-primary-action]",
|
|
"학습자 홈 핵심 행동",
|
|
);
|
|
await expectReadableButtonContrast(page, "학습 대상 선택");
|
|
await expectNoHorizontalOverflow(page);
|
|
await capture(page, testInfo.project.name, "home");
|
|
|
|
await primaryAction.click();
|
|
await expect(page).toHaveURL(/\/learn\/practice$/);
|
|
if (testInfo.project.name.includes("mobile")) {
|
|
await expectLearnerMobileNavIntegrity(page);
|
|
}
|
|
const persona = page.getByRole("option", { name: /P1/ });
|
|
await persona.click();
|
|
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
|
await expect(page).toHaveURL(/\/learn\/session\/P1$/);
|
|
|
|
const goals = page.getByRole("group", { name: "이번 회기 목표 선택" });
|
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await goals.getByRole("button", { name: /개입/ }).click();
|
|
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/learn/session/${PRIMARY_SESSION_ID}$`),
|
|
);
|
|
expect(fixture.startRequests[0]).toMatchObject({
|
|
persona_code: "P1",
|
|
theory_mode: "humanistic",
|
|
goal_stages: ["라포", "탐색", "개입"],
|
|
});
|
|
|
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
|
await page.getByLabel("학습자 발화 입력").fill(LEARNER_TEXT);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible();
|
|
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
|
|
if (testInfo.project.name.includes("mobile")) {
|
|
await expect(
|
|
page.getByRole("region", { name: "현재 회기 요약" }),
|
|
).toContainText("호소 문제와 일상을 함께 이해");
|
|
} else {
|
|
await expect(page.getByText(/민서 · 탐색 단계/)).toBeVisible();
|
|
}
|
|
await expectNoHorizontalOverflow(page);
|
|
if (testInfo.project.name.includes("mobile")) {
|
|
for (const viewport of [
|
|
{ width: 390, height: 844, stage: "active-session" },
|
|
{ width: 320, height: 568, stage: "active-session-320x568" },
|
|
]) {
|
|
await page.setViewportSize(viewport);
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
await expectMobileActiveVisualIntegrity(page);
|
|
await expectCoachNudgeContrast(page);
|
|
await expectNoHorizontalOverflow(page);
|
|
await capture(page, testInfo.project.name, viewport.stage);
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
} else {
|
|
await capture(page, testInfo.project.name, "active-session");
|
|
}
|
|
|
|
await page.getByRole("button", { name: "회기 종료" }).click();
|
|
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/learn/session/${PRIMARY_SESSION_ID}/review$`),
|
|
);
|
|
expect(fixture.primaryEnded).toBe(true);
|
|
await expect(
|
|
page.getByText("평가 AI가 저장된 축어록을 분석했습니다."),
|
|
).toBeVisible();
|
|
const reviewTablist = page.getByRole("tablist", { name: "리뷰 영역 선택" });
|
|
const reviewTabs = reviewTablist.getByRole("tab");
|
|
await expect(reviewTabs).toHaveCount(3);
|
|
for (const tabName of ["축어록", "피드백", "워크시트"]) {
|
|
const tab = reviewTablist.getByRole("tab", { name: tabName });
|
|
const panelIds = (await tab.getAttribute("aria-controls"))?.split(/\s+/) ?? [];
|
|
expect(panelIds.length, `${tabName} controls`).toBeGreaterThan(0);
|
|
for (const panelId of panelIds) {
|
|
await expect(page.locator(`#${panelId}`)).toHaveAttribute("role", "tabpanel");
|
|
}
|
|
}
|
|
const transcriptTab = reviewTablist.getByRole("tab", { name: "축어록" });
|
|
const feedbackTab = reviewTablist.getByRole("tab", { name: "피드백" });
|
|
const worksheetTab = reviewTablist.getByRole("tab", { name: "워크시트" });
|
|
await transcriptTab.focus();
|
|
await page.keyboard.press("End");
|
|
await expect(worksheetTab).toBeFocused();
|
|
await expect(worksheetTab).toHaveAttribute("aria-selected", "true");
|
|
await expect(page.locator("#sr-panel-worksheet")).toBeVisible();
|
|
await page.keyboard.press("Home");
|
|
await expect(transcriptTab).toBeFocused();
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(feedbackTab).toBeFocused();
|
|
await page.keyboard.press("ArrowLeft");
|
|
await expect(transcriptTab).toBeFocused();
|
|
await expect(transcriptTab).toHaveAttribute("tabindex", "0");
|
|
await expect(feedbackTab).toHaveAttribute("tabindex", "-1");
|
|
await expectMinimumHitTargets(page, ".sr-tabs [role='tab']", "리뷰 탭");
|
|
if (testInfo.project.name.includes("mobile")) {
|
|
await expectMinimumHitTargets(page, ".sr-chip-toggle", "축어록 필터");
|
|
await expectMinimumHitTargets(page, ".sr-actions .vg-btn", "리뷰 주요 행동");
|
|
}
|
|
await feedbackTab.click();
|
|
await expect(page.locator("#sr-panel-insights")).toBeVisible();
|
|
await expect(page.locator("#sr-panel-feedback")).toBeVisible();
|
|
await expect(page.locator(".sr-root")).not.toContainText(PRIMARY_SESSION_ID);
|
|
const practiceCard = page.locator(".dp-card");
|
|
await expect(practiceCard).toBeVisible();
|
|
await expect(practiceCard).toContainText("공감적 반영");
|
|
await expect(practiceCard).toContainText(
|
|
"핵심 정서를 한 문장으로 반영한 뒤 이해가 맞는지 확인한다.",
|
|
);
|
|
await expect(practiceCard).toContainText("행동 근거를 더 확인해야 합니다");
|
|
await expect(practiceCard).toContainText(
|
|
"숙달 확인에는 익숙한 장면과 다른 장면·다른 문장이 모두 필요합니다.",
|
|
);
|
|
await expect(practiceCard).toContainText("감정 반영 후 이해 확인");
|
|
await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증");
|
|
await expect(practiceCard).not.toContainText("criterion.reflect-and-check");
|
|
await expect(practiceCard).not.toContainText("unseen_transfer_not_verified");
|
|
await expectNoHorizontalOverflow(page);
|
|
await practiceCard.scrollIntoViewIfNeeded();
|
|
await capture(page, testInfo.project.name, "review-prescription");
|
|
|
|
const launch = practiceCard.getByRole("link", {
|
|
name: "이 처방으로 연습 시작",
|
|
});
|
|
await expect(launch).toBeVisible();
|
|
await launch.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page).toHaveURL(/\/learn\/practice\?/);
|
|
const practiceHomeUrl = new URL(page.url());
|
|
expect(parsePracticeLaunchIntent(practiceHomeUrl.searchParams)).toEqual({
|
|
kind: "deliberate",
|
|
prescriptionId: PRESCRIPTION_ID,
|
|
suiteId: null,
|
|
trialId: null,
|
|
sourceSessionId: PRIMARY_SESSION_ID,
|
|
criterionId: "criterion.reflect-and-check",
|
|
novelty: "familiar",
|
|
mode: "replay",
|
|
});
|
|
await expect(
|
|
page.getByRole("heading", { name: "장면 다시 보기 처방을 이어받았습니다." }),
|
|
).toBeVisible();
|
|
const launchDetails = page.locator(
|
|
"details.lh-practice-launch-intent__details",
|
|
);
|
|
await expect(launchDetails).not.toHaveAttribute("open", "");
|
|
await expect(launchDetails.locator("summary")).toContainText(
|
|
"감정 반영 후 이해 확인",
|
|
);
|
|
await expect(launchDetails.locator("dl")).not.toBeVisible();
|
|
await expect(page.locator(".lh-practice-launch-intent")).toContainText(
|
|
"원본 회기의 내담자를 우선 선택했으며",
|
|
);
|
|
await expect(page.locator(".lh-root")).toContainText("감정 반영 후 이해 확인");
|
|
await expect(page.locator(".lh-root")).toContainText("원본 회기 참조");
|
|
await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check");
|
|
await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8));
|
|
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".sx-page--prestart")).toContainText("인간중심 · 인지행동");
|
|
await expect(page.locator(".sx-page--prestart")).toContainText("감정 반영 후 이해 확인");
|
|
await expect(page.locator(".sx-page--prestart")).toContainText("원본 회기 참조");
|
|
await expect(page.locator(".sx-page--prestart")).not.toContainText("humanistic, cbt");
|
|
await expect(page.locator(".sx-page--prestart")).not.toContainText("criterion.reflect-and-check");
|
|
await expect(page.locator(".sx-page--prestart")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8));
|
|
const prescribedSettings = page.locator(
|
|
"details.sx-prestart__settings.is-prescribed",
|
|
);
|
|
await expect(prescribedSettings).not.toHaveAttribute("open", "");
|
|
await expect(prescribedSettings.locator("summary")).toContainText(
|
|
"인간중심",
|
|
);
|
|
await expect(prescribedSettings.locator(".sx-prestart__settings-body")).not.toBeVisible();
|
|
await expectInsideInitialViewport(
|
|
page,
|
|
".sx-prestart__actions .vg-btn",
|
|
"처방 재연습 시작 행동",
|
|
);
|
|
await expectNoHorizontalOverflow(page);
|
|
await capture(page, testInfo.project.name, "repractice-prestart");
|
|
|
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/learn/session/${RETRY_SESSION_ID}\\?`),
|
|
);
|
|
const retryUrl = new URL(page.url());
|
|
expect(parsePracticeLaunchIntent(retryUrl.searchParams)).toEqual({
|
|
kind: "deliberate",
|
|
prescriptionId: PRESCRIPTION_ID,
|
|
suiteId: null,
|
|
trialId: null,
|
|
sourceSessionId: PRIMARY_SESSION_ID,
|
|
criterionId: "criterion.reflect-and-check",
|
|
novelty: "familiar",
|
|
mode: "replay",
|
|
});
|
|
expect(fixture.startRequests[1]).toMatchObject({
|
|
persona_code: "P1",
|
|
goal_stages: ["라포", "탐색"],
|
|
});
|
|
await expect(page.locator('.sx-page[data-practice-mode="replay"]')).toBeVisible();
|
|
await expectNoHorizontalOverflow(page);
|
|
|
|
await page.getByLabel("학습자 발화 입력").fill(LEARNER_TEXT);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
await expect(
|
|
page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "회기 종료" }).click();
|
|
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/learn/session/${RETRY_SESSION_ID}/review\\?`),
|
|
);
|
|
expect(fixture.retryEnded).toBe(true);
|
|
const retryReviewUrl = new URL(page.url());
|
|
expect(parsePracticeLaunchIntent(retryReviewUrl.searchParams)).toEqual({
|
|
kind: "deliberate",
|
|
prescriptionId: PRESCRIPTION_ID,
|
|
suiteId: null,
|
|
trialId: null,
|
|
sourceSessionId: PRIMARY_SESSION_ID,
|
|
criterionId: "criterion.reflect-and-check",
|
|
novelty: "familiar",
|
|
mode: "replay",
|
|
});
|
|
|
|
const retryFeedbackTab = page.getByRole("tab", { name: "피드백" });
|
|
await expect(retryFeedbackTab).toHaveAttribute("aria-selected", "true");
|
|
await expectAtMainScrollStart(
|
|
page,
|
|
".sr-practice-result-jump.is-deliberate",
|
|
"G4 연습 근거 진입 행동",
|
|
);
|
|
await page
|
|
.getByRole("button", { name: "이번 회기 연습 근거 확인" })
|
|
.click();
|
|
const observation = page.locator(".dp-runtime-observation");
|
|
await expect(observation).toBeVisible();
|
|
await expect(
|
|
observation.getByRole("heading", {
|
|
name: "이번 재연습을 서버 근거로 확인합니다",
|
|
}),
|
|
).toBeVisible();
|
|
await expect(observation).toContainText("반영 전");
|
|
await expect(observation).toContainText("행동 근거를 더 확인해야 합니다");
|
|
await expect(observation).not.toContainText(PRESCRIPTION_ID);
|
|
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
|
|
|
|
await observation
|
|
.getByRole("button", { name: "이번 회기를 독립 관찰로 반영" })
|
|
.click();
|
|
await expect(observation).toContainText("평가 근거 준비 중");
|
|
await expect(observation).toContainText(
|
|
"평가가 준비되면 같은 회기를 중복 없이 이어서 확인합니다.",
|
|
);
|
|
|
|
const readsBeforeSuccess = fixture.practiceReadCount;
|
|
await observation
|
|
.getByRole("button", { name: "평가 상태 다시 확인" })
|
|
.click();
|
|
await expect(observation).toContainText("서버 독립 관찰 반영 완료");
|
|
await expect(observation).toContainText("반영 후");
|
|
await expect(observation).toContainText("새 장면 확인 대기");
|
|
await expect(observation).toContainText("익숙한 장면 근거 1회");
|
|
await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan(
|
|
readsBeforeSuccess,
|
|
);
|
|
await observation.scrollIntoViewIfNeeded();
|
|
await capture(page, testInfo.project.name, "repractice-observation");
|
|
|
|
await observation
|
|
.getByRole("button", { name: "반영 상태 다시 확인" })
|
|
.click();
|
|
await expect(observation).toContainText(
|
|
"같은 회기 근거를 중복 없이 확인했습니다.",
|
|
);
|
|
expect(fixture.runtimeObservationRequests).toHaveLength(3);
|
|
for (const request of fixture.runtimeObservationRequests) {
|
|
expect(request.method).toBe("POST");
|
|
expect(request.url).toContain(
|
|
`/practice/${PRESCRIPTION_ID}/attempts/from-session/${RETRY_SESSION_ID}`,
|
|
);
|
|
expect(request.body).toBeNull();
|
|
}
|
|
await expectNoHorizontalOverflow(page);
|
|
await expectNoPhysicalMediaAccess(page);
|
|
});
|
|
});
|