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> = []; 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, ); 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(".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(".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("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(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 sessionbar = visibleRect(".sx-sessionbar"); const timebar = visibleRect(".sx-timebar"); const mobileContext = visibleRect(".sx-mobile-context"); const transcript = visibleRect(".sx-transcript"); const transcriptHead = visibleRect(".sx-transcript__head"); const scroll = visibleRect(".sx-transcript__scroll"); const compose = visibleRect(".sx-compose"); const controlbar = visibleRect(".sx-controlbar"); const replies = Array.from( document.querySelectorAll(".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}`, compactStage: innerWidth <= 420 && innerHeight <= 620, avatarHidden: avatar == null, geometry: { scroll: scroll ? { top: scroll.top, bottom: scroll.bottom, height: scroll.height } : null, scrollClientHeight: document.querySelector(".sx-transcript__scroll")?.clientHeight ?? null, scrollHeight: document.querySelector(".sx-transcript__scroll")?.scrollHeight ?? null, scrollTop: document.querySelector(".sx-transcript__scroll")?.scrollTop ?? null, latestReply: latestReply ? { top: latestReply.top, bottom: latestReply.bottom, height: latestReply.height } : null, sessionbar: sessionbar ? { top: sessionbar.top, bottom: sessionbar.bottom, height: sessionbar.height } : null, timebar: timebar ? { top: timebar.top, bottom: timebar.bottom, height: timebar.height } : null, mobileContext: mobileContext ? { top: mobileContext.top, bottom: mobileContext.bottom, height: mobileContext.height } : null, transcript: transcript ? { top: transcript.top, bottom: transcript.bottom, height: transcript.height } : null, transcriptHead: transcriptHead ? { top: transcriptHead.top, bottom: transcriptHead.bottom, height: transcriptHead.height } : null, compose: compose ? { top: compose.top, bottom: compose.bottom, height: compose.height } : null, stage: stage ? { top: stage.top, bottom: stage.bottom, height: stage.height } : null, controlbar: controlbar ? { top: controlbar.top, bottom: controlbar.bottom, height: controlbar.height } : null, }, 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), ), }; }); const diagnostic = `${result.viewport} ${JSON.stringify(result.geometry)}`; expect(result.overlaps, diagnostic).toEqual([]); if (result.compactStage) { expect(result.avatarHidden, diagnostic).toBe(true); } else { expect(result.stageContainsAvatar, diagnostic).toBe(true); } expect(result.stageContainsClient, diagnostic).toBe(true); expect(result.statusHidden, diagnostic).toBe(true); expect(result.transcriptContained, diagnostic).toBe(true); expect(result.composeContained, diagnostic).toBe(true); expect(result.latestReplyVisible, diagnostic).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("3.5초 경계의 자기주도 경로 전환이 복구되면 부트 진단을 즉시 닫는다", async ({ page, }) => { await routeSelfDirectedUiFixture(page); await page.goto("/learn"); await expect( page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }), ).toBeVisible(); // index.html의 첫 watchdog(3.5초)과 실제 자기주도 경로 전환을 겹친다. // route가 잠깐 비어 보이는 동안에는 진단이 뜨되, React 콘텐츠 복구 후에는 // 두 번째 watchdog(8초)을 기다리지 않고 바로 사라져야 한다. await page.evaluate(async () => { const remaining = Math.max(0, 3_300 - performance.now()); await new Promise((resolve) => window.setTimeout(resolve, remaining)); const root = document.getElementById("root"); const action = document.querySelector( "[data-learner-primary-action]", ); if (!root || !action) { throw new Error("self-directed route transition fixture unavailable"); } root.style.visibility = "hidden"; action.click(); }); await expect(page).toHaveURL(/\/learn\/practice$/); await expect(page.locator("#vignette-boot-diagnostic")).toBeVisible({ timeout: 1_200, }); await page.evaluate(() => { document.getElementById("root")?.style.removeProperty("visibility"); }); await expect( page.getByRole("heading", { name: "연습할 내담자를 선택합니다." }), ).toBeVisible(); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0, { timeout: 1_000, }); }); test("React 부트가 실제로 비어 있으면 진단을 유지한다", async ({ page }) => { await page.route( /\/(?:src\/main\.tsx|assets\/index-[^/]+\.js)(?:\?.*)?$/, (route) => route.abort("failed"), ); await page.goto("/learn", { waitUntil: "domcontentloaded" }); const diagnostic = page.locator("#vignette-boot-diagnostic"); await expect(diagnostic).toBeVisible({ timeout: 4_500 }); await expect(diagnostic).toContainText("React 화면이 정상 콘텐츠를 표시하지 못해서"); await page.waitForTimeout(250); await expect(diagnostic).toBeVisible(); await expect(page.locator("#root")).toBeEmpty(); }); 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); if (testInfo.project.name.includes("mobile")) { await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectInsideInitialViewport( page, "[data-learner-primary-action]", "320px 학습자 홈 핵심 행동", ); await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭"); await expectNoHorizontalOverflow(page); await capture(page, testInfo.project.name, "home-320x568"); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); } 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(); await expect(page.locator(".sx-page")).not.toContainText("[NAME]"); 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 expect(page.locator(".sr-root")).not.toContainText("[NAME]"); await expectNoHorizontalOverflow(page); if (testInfo.project.name.includes("mobile")) { await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await practiceCard.scrollIntoViewIfNeeded(); await expectMinimumHitTargets( page, ".dp-launch-ticket__cta", "320px 처방 연습 시작 행동", ); await expectNoHorizontalOverflow(page); await capture(page, testInfo.project.name, "review-prescription-320x568"); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); } 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 expect(page.locator(".lh-root")).not.toContainText("[NAME]"); if (testInfo.project.name.includes("mobile")) { await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동"); await expectNoHorizontalOverflow(page); await capture(page, testInfo.project.name, "repractice-home-320x568"); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); } 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); if (testInfo.project.name.includes("mobile")) { await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await expectInsideInitialViewport( page, ".sx-prestart__actions .vg-btn", "320px 처방 재연습 시작 행동", ); await expectMinimumHitTargets( page, ".sx-prestart__actions .vg-btn", "320px 처방 재연습 시작 행동", ); await expectNoHorizontalOverflow(page); await capture(page, testInfo.project.name, "repractice-prestart-320x568"); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); } 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, ); if (testInfo.project.name.includes("mobile")) { await page.setViewportSize({ width: 320, height: 568 }); await page.evaluate(() => new Promise(requestAnimationFrame)); await observation.scrollIntoViewIfNeeded(); await expectMinimumHitTargets( page, ".dp-runtime-observation button", "320px 서버 관찰 행동", ); await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8)); await expect(observation).not.toContainText("[NAME]"); await expectNoHorizontalOverflow(page); await capture(page, testInfo.project.name, "repractice-observation-320x568"); await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(() => new Promise(requestAnimationFrame)); } 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); }); });