vignette/apps/web/e2e/self-directed-learning-loop.spec.ts

1394 lines
53 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 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<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}`,
compactStage: innerWidth <= 420 && innerHeight <= 620,
avatarHidden: avatar == null,
geometry: {
scroll: scroll
? { top: scroll.top, bottom: scroll.bottom, height: scroll.height }
: null,
scrollClientHeight:
document.querySelector<HTMLElement>(".sx-transcript__scroll")?.clientHeight ?? null,
scrollHeight:
document.querySelector<HTMLElement>(".sx-transcript__scroll")?.scrollHeight ?? null,
scrollTop:
document.querySelector<HTMLElement>(".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<HTMLElement>(
"[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);
});
});