710 lines
30 KiB
TypeScript
710 lines
30 KiB
TypeScript
/* =====================================================================
|
|
uc-session-prestart.spec.ts — 유스케이스: 회기 사전 설정(목표 선택) 여정
|
|
|
|
목적: 학습자가 /learn/session/:personaCode 프리스타트 화면에서
|
|
· 내담자(페르소나) 정보·난이도를 확인하고
|
|
· 이번 회기 목표(1~4개)를 선택/해제하고
|
|
· 이론모드를 고른 뒤
|
|
· "회기 시작"으로 active 회기에 진입하는
|
|
실제 사용자 행동을 검증한다.
|
|
|
|
근거: apps/web/src/pages/Session.tsx 프리스타트 렌더(sx-prestart 블록,
|
|
toggleGoal 1~4개 자유 선택, canStartSession/selectedGoals.length 게이트),
|
|
src/lib/practiceLaunchIntent.ts (G4 처방 연습 deliberate / G5 전이 검증
|
|
transfer launch intent 파싱·URL 보존), src/lib/personaViewModel.ts
|
|
(DIFFICULTY_LABEL, isUsablePersona).
|
|
|
|
중복 회피: full-sweep-session.spec.ts(동의 게이트·시간 알람·active 상호작용),
|
|
self-directed-learning-loop.spec.ts(deliberate replay 처방 흐름·목표 추가
|
|
선택 1건)와 겹치지 않도록 목표 0개 차단·전체 선택·해제 토글·키보드 조작·
|
|
transfer(G5) intent·잘못된 intent 차단 각도로 검증한다.
|
|
|
|
전부 route fixture 기반이며 실제 AI 엔진 턴 생성 없음.
|
|
모바일(≤720px)은 진행 초점 패널·칩·힌트 문구를 의도적으로 숨기므로
|
|
(session.css @media max-width:720px) 해당 어설션은 desktop 전용으로 가드한다.
|
|
===================================================================== */
|
|
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const FIXTURE_SESSION_ID = "77777777-7777-4777-8777-777777777777";
|
|
|
|
function jsonRoute(body: unknown, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
interface PrestartFixture {
|
|
startRequests: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
/** 프리스타트 여정용 route fixture — catch-all 먼저, 개별 fixture 나중(나중 등록 우선). */
|
|
async function routePrestartFixture(page: Page): Promise<PrestartFixture> {
|
|
const startRequests: Array<Record<string, unknown>> = [];
|
|
|
|
await page.route("**/api/**", (route) =>
|
|
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
|
);
|
|
|
|
await page.route("**/api/auth/me", (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
user_id: "00000000-0000-0000-0000-00000ucps301",
|
|
email: "prestart.learner@hs.ac.kr",
|
|
display_name: "프리스타트 학습자",
|
|
role: "learner",
|
|
admin_access: false,
|
|
super_admin: false,
|
|
account_status: "approved",
|
|
approval_required: false,
|
|
cohort_ids: [],
|
|
consent_at: Math.floor(Date.now() / 1000),
|
|
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
|
nickname: "프리스타트 학습자",
|
|
self_introduction: "회기 사전 설정 유스케이스 검증용 학습자입니다.",
|
|
avatar_url: "",
|
|
}),
|
|
),
|
|
);
|
|
|
|
await page.route("**/api/personas", (route) =>
|
|
route.fulfill(
|
|
jsonRoute([
|
|
{
|
|
code: "P1",
|
|
display_name: "민서(청소년 우울)",
|
|
difficulty: "hard",
|
|
theory_target: ["humanistic"],
|
|
demographics: { age_band: "10대" },
|
|
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
|
voice_preset: "soft-young-fem",
|
|
source: "database",
|
|
degraded: false,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
|
|
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
pilot_id: "phase3-pilot-draft",
|
|
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
|
measures: [],
|
|
complete_pre_count: 0,
|
|
complete_post_count: 0,
|
|
updated_at: null,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await page.route("**/api/voice/health", (route) =>
|
|
route.fulfill(jsonRoute({ available: true, reason: null })),
|
|
);
|
|
|
|
await page.route("**/api/sessions", async (route) => {
|
|
if (route.request().method() !== "POST") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
const body = (route.request().postDataJSON() ?? {}) as Record<string, unknown>;
|
|
startRequests.push(body);
|
|
await route.fulfill(
|
|
jsonRoute(
|
|
{
|
|
session_id: FIXTURE_SESSION_ID,
|
|
case_id: "uc-prestart-case-001",
|
|
session_no: 1,
|
|
stage: "라포",
|
|
effective_openness: 0.21,
|
|
recall_summary: null,
|
|
degraded: false,
|
|
goal_stages: body.goal_stages ?? ["라포", "탐색"],
|
|
duration_limit_seconds: 3600,
|
|
warning_before_end_seconds: 600,
|
|
},
|
|
201,
|
|
),
|
|
);
|
|
});
|
|
|
|
// 시작 직후 URL 전환은 justStartedSessionRef로 재로딩을 건너뛰지만,
|
|
// 안전망으로 재개 스냅샷도 결정론적으로 응답한다.
|
|
await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}`, (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
session_id: FIXTURE_SESSION_ID,
|
|
case_id: "uc-prestart-case-001",
|
|
persona_code: "P1",
|
|
persona_name: "민서",
|
|
session_no: 1,
|
|
status: "active",
|
|
stage: "라포",
|
|
theory_mode: "humanistic",
|
|
effective_openness: 0.21,
|
|
started_at: new Date().toISOString(),
|
|
ended_at: null,
|
|
review_ready: false,
|
|
turns: [],
|
|
goal_stages: ["라포", "탐색"],
|
|
progress: null,
|
|
duration_limit_seconds: 3600,
|
|
warning_before_end_seconds: 600,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}/alliance-pulses`, (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
items: [
|
|
{
|
|
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab",
|
|
checkpoint: "pre",
|
|
status: "ready",
|
|
learner_locked_at: new Date().toISOString(),
|
|
revealed_at: new Date().toISOString(),
|
|
error_code: null,
|
|
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
|
measurements: [],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
await page.route(`**/api/sessions/${FIXTURE_SESSION_ID}/live-coach`, (route) => {
|
|
if (route.request().method() === "GET") {
|
|
route.fulfill(
|
|
jsonRoute({
|
|
source: "database",
|
|
quota: { remaining: 3, max: 3 },
|
|
credit_events: [],
|
|
events: [],
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
route.fulfill(jsonRoute({ detail: "not used in prestart usecases" }, 404));
|
|
});
|
|
|
|
return { startRequests };
|
|
}
|
|
|
|
function goalsGroup(page: Page) {
|
|
return page.getByRole("group", { name: "이번 회기 목표 선택" });
|
|
}
|
|
|
|
function startButton(page: Page) {
|
|
return page.getByRole("button", { name: "회기 시작" });
|
|
}
|
|
|
|
async function gotoPrestart(page: Page, search = "") {
|
|
await page.goto(`/learn/session/P1${search}`);
|
|
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
|
}
|
|
|
|
// G5 전이 검증(transfer) launch intent 쿼리 — practiceLaunchSearch와 동일한 파라미터 구성.
|
|
const TRANSFER_LAUNCH_SEARCH =
|
|
"?launch=transfer" +
|
|
"&prescription=rx-transfer-001" +
|
|
"&source_session=88888888-8888-4888-8888-888888888888" +
|
|
"&criterion=competency.empathic_reflection" +
|
|
"&novelty=unseen_transfer" +
|
|
"&mode=counterevidence_forecast" +
|
|
"&suite=suite-empathy-01" +
|
|
"&trial=trial-empathy-01a";
|
|
|
|
// G4 처방 연습(deliberate) launch intent 쿼리 — self-directed 스펙의 replay와 겹치지 않게
|
|
// difficulty_ladder 모드를 쓴다.
|
|
const DELIBERATE_LAUNCH_SEARCH =
|
|
"?launch=deliberate" +
|
|
"&prescription=rx-ladder-001" +
|
|
"&source_session=99999999-9999-4999-8999-999999999999" +
|
|
"&criterion=competency.open_question" +
|
|
"&novelty=familiar" +
|
|
"&mode=difficulty_ladder";
|
|
|
|
test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|
// usecase: 학습자가 프리스타트 화면에서 내담자 이름·호소 문제·난이도를 확인한다
|
|
test("시작 전 화면에서 내담자 정보와 고난도 표시를 확인한다", async ({ page }, testInfo) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "민서님과의 회기를 시작할까요?" }),
|
|
).toBeVisible();
|
|
|
|
const facts = page.locator(".sx-prestart__facts");
|
|
await expect(facts).toContainText("호소");
|
|
await expect(facts).toContainText("자퇴와 무기력감을 둘러싼 상담 연습");
|
|
await expect(facts).toContainText("난도");
|
|
await expect(facts).toContainText("고난도");
|
|
await expect(facts).toContainText("인간중심");
|
|
|
|
if (!testInfo.project.name.includes("mobile")) {
|
|
// 데스크톱: 아바타 카드의 내담자 요약 + 태그 칩(P1·고난도)까지 확인한다.
|
|
const caseCard = page.locator(".sx-prestart__case");
|
|
await expect(caseCard).toContainText("민서");
|
|
await expect(caseCard).toContainText("교육용 가상 내담자");
|
|
const chips = page.locator(".sx-prestart__chips");
|
|
await expect(chips).toContainText("P1");
|
|
await expect(chips.locator("span.is-clay")).toHaveText("고난도");
|
|
}
|
|
});
|
|
|
|
// usecase: 학습자가 기본 회기 설정 요약과 첫 발화의 한 가지 초점을 읽는다
|
|
test("기본 회기 설정 요약이 인간중심 이론과 라포 핵심 초점을 보여준다", async ({
|
|
page,
|
|
}) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const settingsSummary = page.locator("details.sx-prestart__settings > summary");
|
|
await expect(settingsSummary).toContainText("인간중심 · 라포");
|
|
await expect(page.locator("details.sx-prestart__settings")).not.toHaveAttribute("open", "");
|
|
|
|
const goals = goalsGroup(page);
|
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute("aria-pressed", "false");
|
|
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"false",
|
|
);
|
|
|
|
const plan = page.locator(".sx-prestart__plan");
|
|
await expect(plan).toContainText("준비");
|
|
await expect(plan).toContainText("대화");
|
|
await expect(plan).toContainText("리뷰·재연습");
|
|
await expect(plan).toContainText("라포에 집중");
|
|
});
|
|
|
|
// usecase: 키보드 사용자는 전역 탐색을 지나 곧바로 회기 준비 본문으로 이동한다.
|
|
test("첫 Tab의 본문 건너뛰기가 회기 준비 본문에 포커스를 둔다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const skipLink = page.getByRole("link", { name: "본문으로 건너뛰기" });
|
|
await page.keyboard.press("Tab");
|
|
await expect(skipLink).toBeFocused();
|
|
await expect(skipLink).toBeVisible();
|
|
|
|
await skipLink.press("Enter");
|
|
await expect(page.locator("main#vg-main-content")).toBeFocused();
|
|
});
|
|
|
|
// usecase: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다
|
|
test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({
|
|
page,
|
|
}) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
await expect(startButton(page)).toBeEnabled();
|
|
await goals.getByRole("button", { name: /라포/ }).click();
|
|
|
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"false",
|
|
);
|
|
await expect(startButton(page)).toBeDisabled();
|
|
await expect(
|
|
page.locator("details.sx-prestart__settings > summary"),
|
|
).toContainText("핵심 초점 선택 필요");
|
|
await expect(page.getByRole("heading", { name: "초점 선택 필요" })).toBeVisible();
|
|
await expect(page.locator(".sx-prestart__plan")).toContainText(
|
|
"목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다.",
|
|
);
|
|
await expect(
|
|
page.getByText("오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다
|
|
test("목표를 다시 1개 선택하면 시작 차단이 풀린다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
await goals.getByRole("button", { name: /라포/ }).click();
|
|
await expect(startButton(page)).toBeDisabled();
|
|
|
|
await goals.getByRole("button", { name: /개입/ }).click();
|
|
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(startButton(page)).toBeEnabled();
|
|
await expect(
|
|
page.locator("details.sx-prestart__settings > summary"),
|
|
).toContainText("인간중심 · 개입");
|
|
});
|
|
|
|
// usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다
|
|
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
const goalButtons = goals.getByRole("button");
|
|
// 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약.
|
|
await expect(goalButtons).toHaveCount(4);
|
|
|
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
|
await goals.getByRole("button", { name: /개입/ }).click();
|
|
await goals.getByRole("button", { name: /정리/ }).click();
|
|
for (const name of [/라포/, /탐색/, /개입/, /정리/]) {
|
|
await expect(goals.getByRole("button", { name })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
}
|
|
await expect(
|
|
page.locator("details.sx-prestart__settings > summary"),
|
|
).toContainText("라포 · 탐색 · 개입 · 정리");
|
|
await expect(startButton(page)).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 보조 목표가 생겨도 '먼저 선택한 것'에 묶이지 않고, 학습자가 핵심 초점을 명시적으로 바꾼다.
|
|
test("보조 목표를 핵심 초점으로 바꾸면 저장 순서도 함께 바뀐다", async ({ page }) => {
|
|
const fixture = await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
|
|
|
const primarySwitch = page.getByRole("group", { name: "핵심 초점 바꾸기" });
|
|
await expect(page.locator(".sx-prestart__primary-switch")).toContainText("보조 목표");
|
|
await primarySwitch.getByRole("button", { name: "탐색을 핵심 초점으로 설정" }).click();
|
|
|
|
await expect(page.locator(".sx-prestart__focus").getByRole("heading")).toHaveText("탐색");
|
|
await expect(
|
|
page.locator("details.sx-prestart__settings > summary"),
|
|
).toContainText("인간중심 · 탐색 · 라포");
|
|
|
|
await startButton(page).click();
|
|
expect(fixture.startRequests).toHaveLength(1);
|
|
expect(fixture.startRequests[0]).toMatchObject({ goal_stages: ["탐색", "라포"] });
|
|
});
|
|
|
|
// usecase: 높이가 짧은 데스크톱에서도 1~4개 목표의 핵심 전환과 시작 행동이 서로 가려지지 않는다.
|
|
test("짧은 데스크톱에서 4개 목표와 회기 시작 행동이 겹치지 않는다", async ({ page }, testInfo) => {
|
|
test.skip(testInfo.project.name.includes("mobile"), "데스크톱 중앙 열의 짧은 높이 회귀를 검증한다.");
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
const fixture = await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
|
await goals.getByRole("button", { name: /개입/ }).click();
|
|
await goals.getByRole("button", { name: /정리/ }).click();
|
|
|
|
// 짧은 높이에서는 세 개의 큰 보조 버튼 대신 같은 선택지를 가진 native select를 쓴다.
|
|
const compactPrimarySelect = page.locator(".sx-prestart__primary-select select");
|
|
await expect(compactPrimarySelect).toBeVisible();
|
|
await compactPrimarySelect.selectOption("탐색");
|
|
await expect(page.locator(".sx-prestart__focus").getByRole("heading")).toHaveText("탐색");
|
|
|
|
const geometry = await page.evaluate(() => {
|
|
const main = document.querySelector<HTMLElement>(".vg-main");
|
|
const actions = document.querySelector<HTMLElement>(".sx-prestart__actions");
|
|
const cta = actions?.querySelector<HTMLElement>(".vg-btn");
|
|
const primaryControl = document.querySelector<HTMLElement>(".sx-prestart__primary-select select");
|
|
if (!main || !actions || !cta || !primaryControl) throw new Error("prestart geometry targets missing");
|
|
main.scrollTo({ top: 0 });
|
|
const rect = (element: HTMLElement) => {
|
|
const { top, bottom } = element.getBoundingClientRect();
|
|
return { top, bottom };
|
|
};
|
|
return {
|
|
main: rect(main),
|
|
actions: rect(actions),
|
|
cta: rect(cta),
|
|
primaryControl: rect(primaryControl),
|
|
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
|
};
|
|
});
|
|
expect(geometry.cta.bottom).toBeLessThanOrEqual(geometry.main.bottom);
|
|
expect(geometry.primaryControl.bottom).toBeLessThanOrEqual(geometry.actions.top);
|
|
expect(geometry.hasHorizontalOverflow).toBe(false);
|
|
|
|
await startButton(page).click();
|
|
expect(fixture.startRequests).toHaveLength(1);
|
|
expect(fixture.startRequests[0]).toMatchObject({
|
|
goal_stages: ["탐색", "라포", "개입", "정리"],
|
|
});
|
|
});
|
|
|
|
// usecase: 모바일에서는 읽는 순서와 bottom bar 위의 시작 행동을 동시에 보장한다.
|
|
test("모바일에서 사례·학습·재연습 순서와 시작 CTA 안전 간격을 지킨다", async ({ page }, testInfo) => {
|
|
test.skip(!testInfo.project.name.includes("mobile"), "모바일 문서 순서와 bottom bar 관계를 검증한다.");
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const geometry = await page.evaluate(() => {
|
|
const main = document.querySelector<HTMLElement>(".vg-main");
|
|
const caseCard = document.querySelector<HTMLElement>(".sx-prestart__visual");
|
|
const learning = document.querySelector<HTMLElement>(".sx-prestart__learning");
|
|
const map = document.querySelector<HTMLElement>(".sx-prestart__plan");
|
|
const actions = document.querySelector<HTMLElement>(".sx-prestart__actions");
|
|
const cta = actions?.querySelector<HTMLElement>(".vg-btn");
|
|
const bottomNav = document.querySelector<HTMLElement>('nav[aria-label="주 메뉴"]');
|
|
if (!main || !caseCard || !learning || !map || !actions || !cta || !bottomNav) {
|
|
throw new Error("mobile prestart geometry targets missing");
|
|
}
|
|
main.scrollTo({ top: 0 });
|
|
const rect = (element: HTMLElement) => {
|
|
const { top, bottom } = element.getBoundingClientRect();
|
|
return { top, bottom };
|
|
};
|
|
return {
|
|
caseCard: rect(caseCard),
|
|
learning: rect(learning),
|
|
map: rect(map),
|
|
actions: rect(actions),
|
|
cta: rect(cta),
|
|
bottomNav: rect(bottomNav),
|
|
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
|
};
|
|
});
|
|
expect(geometry.caseCard.top).toBeLessThan(geometry.learning.top);
|
|
expect(geometry.learning.top).toBeLessThan(geometry.map.top);
|
|
expect(geometry.cta.bottom).toBeLessThanOrEqual(geometry.bottomNav.top - 8);
|
|
expect(geometry.hasHorizontalOverflow).toBe(false);
|
|
});
|
|
|
|
// usecase: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다
|
|
test("선택된 목표를 다시 누르면 해제되고 요약이 갱신된다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
const exploreGoal = goals.getByRole("button", { name: /탐색/ });
|
|
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
|
|
|
await exploreGoal.click();
|
|
await expect(exploreGoal).toHaveAttribute("aria-pressed", "true");
|
|
|
|
await exploreGoal.click();
|
|
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
|
await expect(
|
|
page.locator("details.sx-prestart__settings > summary"),
|
|
).toContainText("인간중심 · 라포");
|
|
// 나머지 기본 목표는 그대로 유지된다.
|
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(startButton(page)).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다
|
|
test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({
|
|
page,
|
|
}) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
await page.locator("details.sx-prestart__settings > summary").click();
|
|
const theory = page.getByRole("group", { name: "이론모드 선택" });
|
|
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
|
|
await theory.getByRole("button", { name: /CBT/ }).click();
|
|
await expect(theory.getByRole("button", { name: /CBT/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"false",
|
|
);
|
|
await expect(
|
|
page.locator("details.sx-prestart__settings > summary"),
|
|
).toContainText("CBT · 라포");
|
|
await expect(page.locator(".sx-prestart__settings-note")).toContainText(
|
|
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
|
);
|
|
});
|
|
|
|
// usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다
|
|
test("회기 시작을 누르면 active 화면으로 전환되고 선택한 목표가 요청에 실린다", async ({
|
|
page,
|
|
}) => {
|
|
const fixture = await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
// 기본 선택인 라포 한 가지 초점으로 시작한다.
|
|
await startButton(page).click();
|
|
|
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${FIXTURE_SESSION_ID}$`));
|
|
expect(fixture.startRequests).toHaveLength(1);
|
|
expect(fixture.startRequests[0]).toMatchObject({
|
|
persona_code: "P1",
|
|
theory_mode: "humanistic",
|
|
goal_stages: ["라포"],
|
|
});
|
|
// active 전환 뒤 목표 단계에 '목표' 배지가 붙는다(시작 응답의 goal_stages 반영).
|
|
await expect(page.locator(".sx-ph.is-goal")).toHaveCount(1);
|
|
});
|
|
|
|
// usecase: 학습자가 마우스 없이 키보드만으로 목표를 선택/해제한다
|
|
test("키보드 Space·Enter로 목표를 토글할 수 있다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const goals = goalsGroup(page);
|
|
const interveneGoal = goals.getByRole("button", { name: /개입/ });
|
|
await expect(interveneGoal).toHaveAttribute("aria-pressed", "false");
|
|
|
|
await interveneGoal.focus();
|
|
await expect(interveneGoal).toBeFocused();
|
|
await page.keyboard.press("Space");
|
|
await expect(interveneGoal).toHaveAttribute("aria-pressed", "true");
|
|
|
|
await page.keyboard.press("Enter");
|
|
await expect(interveneGoal).toHaveAttribute("aria-pressed", "false");
|
|
// 키보드 해제 뒤에도 남은 목표로 시작은 계속 가능하다.
|
|
await expect(startButton(page)).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 단축키 도움말은 모달 선언과 같은 포커스 규칙을 지키며, 닫으면 호출자로 돌아간다.
|
|
test("단축키 도움말은 포커스를 가두고 닫으면 시작 행동으로 돌려준다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page);
|
|
|
|
const start = startButton(page);
|
|
await start.focus();
|
|
await expect(start).toBeFocused();
|
|
// Playwright에서는 punctuation chord보다 명시한 Ctrl+/ 계약이 키보드 레이아웃에 독립적이다.
|
|
await start.press("Control+/");
|
|
|
|
const dialog = page.getByRole("dialog", { name: "키보드 단축키 안내" });
|
|
const confirm = dialog.getByRole("button", { name: "확인 (Esc)" });
|
|
await expect(dialog).toBeVisible();
|
|
await expect(confirm).toBeFocused();
|
|
await page.keyboard.press("Tab");
|
|
await expect(confirm).toBeFocused();
|
|
|
|
await page.keyboard.press("Escape");
|
|
await expect(dialog).toHaveCount(0);
|
|
await expect(start).toBeFocused();
|
|
});
|
|
|
|
// usecase: 리뷰 처방(G4 deliberate)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다
|
|
test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({
|
|
page,
|
|
}) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page, DELIBERATE_LAUNCH_SEARCH);
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "처방 연습 · 난도 사다리" }),
|
|
).toBeVisible();
|
|
const context = page.locator(".sx-practice-launch-context");
|
|
await expect(context).toContainText("익숙한 장면");
|
|
await expect(context).toContainText("성공 기준 개방형 질문");
|
|
await expect(context).toContainText("원본 회기 참조");
|
|
await expect(context).toContainText("이 출처는 회기 URL에 계속 보존됩니다");
|
|
// 내부 식별자는 학습자 본문에 노출하지 않는다.
|
|
await expect(page.locator(".sx-page--prestart")).not.toContainText(
|
|
"competency.open_question",
|
|
);
|
|
// 처방 진입에서는 회기 설정 상세가 접힌 채 시작 행동에 집중한다.
|
|
const settings = page.locator("details.sx-prestart__settings.is-prescribed");
|
|
await expect(settings).not.toHaveAttribute("open", "");
|
|
await expect(startButton(page)).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 전이 검증(G5 transfer)에서 넘어온 학습자가 전이 과제 맥락을 확인한다
|
|
test("G5 전이 검증 launch intent가 처음 보는 장면 맥락으로 표시된다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await gotoPrestart(page, TRANSFER_LAUNCH_SEARCH);
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "전이 검증 · 반대근거 예측" }),
|
|
).toBeVisible();
|
|
const context = page.locator(".sx-practice-launch-context");
|
|
await expect(context).toContainText("처음 보는 장면");
|
|
await expect(context).toContainText("성공 기준 공감적 반영");
|
|
await expect(context).toContainText("전이 과제 출처 확인됨");
|
|
await expect(context).toContainText("이 출처는 회기 URL에 계속 보존됩니다");
|
|
await expect(page.locator(".sx-page--prestart")).not.toContainText(
|
|
"competency.empathic_reflection",
|
|
);
|
|
await expect(startButton(page)).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 학습자가 전이 검증 처방으로 회기를 시작해도 출처 쿼리가 URL에 그대로 남는다
|
|
test("launch intent 쿼리가 회기 시작 후에도 URL에 유지된다", async ({ page }) => {
|
|
const fixture = await routePrestartFixture(page);
|
|
await gotoPrestart(page, TRANSFER_LAUNCH_SEARCH);
|
|
|
|
await startButton(page).click();
|
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/learn/session/${FIXTURE_SESSION_ID}\\?`),
|
|
);
|
|
|
|
const url = new URL(page.url());
|
|
expect(url.searchParams.get("launch")).toBe("transfer");
|
|
expect(url.searchParams.get("mode")).toBe("counterevidence_forecast");
|
|
expect(url.searchParams.get("novelty")).toBe("unseen_transfer");
|
|
expect(url.searchParams.get("suite")).toBe("suite-empathy-01");
|
|
expect(url.searchParams.get("trial")).toBe("trial-empathy-01a");
|
|
expect(url.searchParams.get("source_session")).toBe(
|
|
"88888888-8888-4888-8888-888888888888",
|
|
);
|
|
expect(fixture.startRequests).toHaveLength(1);
|
|
// active 화면도 처방 모드를 유지한다(data-practice-mode 계약).
|
|
await expect(page.locator(".sx-page--active")).toHaveAttribute(
|
|
"data-practice-mode",
|
|
"counterevidence_forecast",
|
|
);
|
|
});
|
|
|
|
// usecase: 조작된/깨진 처방 링크로 진입한 학습자는 일반 회기로 축약되지 않고 차단 안내를 받는다
|
|
test("검증 불가한 launch 쿼리는 오류 안내와 함께 회기 시작을 차단한다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
// prescription 파라미터가 빠진 deliberate intent — parsePracticeLaunchIntent가 null을 반환.
|
|
await gotoPrestart(
|
|
page,
|
|
"?launch=deliberate&source_session=99999999-9999-4999-8999-999999999999" +
|
|
"&criterion=competency.open_question&novelty=familiar&mode=replay",
|
|
);
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "연습 처방의 출처를 다시 확인해 주세요." }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("alert").filter({
|
|
hasText: "연습 처방의 출처를 검증할 수 없습니다. 일반 회기로 축약하지 않았습니다.",
|
|
}),
|
|
).toBeVisible();
|
|
await expect(startButton(page)).toBeDisabled();
|
|
});
|
|
|
|
// usecase: 학습자가 프리스타트에서 마음을 바꿔 브라우저 뒤로가기로 학습자 홈에 돌아간다
|
|
test("뒤로가기로 프리스타트를 떠나 학습자 홈으로 복귀한다", async ({ page }) => {
|
|
await routePrestartFixture(page);
|
|
await page.goto("/learn");
|
|
await expect(page).toHaveURL(/\/learn$/);
|
|
|
|
await gotoPrestart(page);
|
|
await expect(startButton(page)).toBeVisible();
|
|
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/\/learn$/);
|
|
await expect(page.locator(".sx-page--prestart")).toHaveCount(0);
|
|
await expect(startButton(page)).toHaveCount(0);
|
|
});
|
|
});
|