케이스 이어하기 UI와 공통 탭·이미지 복구를 반영

This commit is contained in:
Yun Chan 2026-09-01 11:45:23 +09:00
parent 72353ecd82
commit f1b80676c1
38 changed files with 3581 additions and 455 deletions

View file

@ -255,69 +255,75 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
}
});
// usecase: 학습자가 기본 선택된 회기 설정 요약(이론모드·목표)을 읽는다
test("기본 회기 설정 요약이 인간중심 이론과 라포·탐색 목표를 보여준다", async ({
// usecase: 학습자가 기본 회기 설정 요약과 첫 발화의 한 가지 초점을 읽는다
test("기본 회기 설정 요약이 인간중심 이론과 라포 핵심 초점을 보여준다", async ({
page,
}, testInfo) => {
}) => {
await routePrestartFixture(page);
await gotoPrestart(page);
const settingsSummary = page.locator("details.sx-prestart__settings > summary");
await expect(settingsSummary).toContainText("인간중심, 라포 · 탐색");
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",
"true",
);
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute("aria-pressed", "false");
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
"aria-pressed",
"false",
);
if (!testInfo.project.name.includes("mobile")) {
const plan = page.locator(".sx-prestart__plan");
await expect(plan).toContainText("2개 선택");
await expect(plan).toContainText("라포 · 탐색");
await expect(plan).toContainText("60분 회기");
await expect(plan).toContainText("종료 10분 전 알림");
}
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,
}, testInfo) => {
}) => {
await routePrestartFixture(page);
await gotoPrestart(page);
const goals = goalsGroup(page);
await expect(startButton(page)).toBeEnabled();
await goals.getByRole("button", { name: /라포/ }).click();
await goals.getByRole("button", { name: /탐색/ }).click();
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
"aria-pressed",
"false",
);
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("목표 선택 필요");
if (!testInfo.project.name.includes("mobile")) {
await expect(
page.getByText("이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."),
).toBeVisible();
await expect(page.locator(".sx-prestart__plan")).toContainText("선택 필요");
}
).toContainText("핵심 초점 선택 필요");
await expect(page.getByRole("heading", { name: "초점 선택 필요" })).toBeVisible();
await expect(page.locator(".sx-prestart__plan")).toContainText(
"목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다.",
);
await expect(
page.getByText("오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."),
).toBeVisible();
});
// usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다
@ -327,7 +333,6 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
const goals = goalsGroup(page);
await goals.getByRole("button", { name: /라포/ }).click();
await goals.getByRole("button", { name: /탐색/ }).click();
await expect(startButton(page)).toBeDisabled();
await goals.getByRole("button", { name: /개입/ }).click();
@ -338,11 +343,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
await expect(startButton(page)).toBeEnabled();
await expect(
page.locator("details.sx-prestart__settings > summary"),
).toContainText("인간중심, 개입");
).toContainText("인간중심 · 개입");
});
// usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }, testInfo) => {
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }) => {
await routePrestartFixture(page);
await gotoPrestart(page);
@ -351,6 +356,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
// 회기 단계는 라포·탐색·개입·정리 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 [/라포/, /탐색/, /개입/, /정리/]) {
@ -363,9 +369,114 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
page.locator("details.sx-prestart__settings > summary"),
).toContainText("라포 · 탐색 · 개입 · 정리");
await expect(startButton(page)).toBeEnabled();
if (!testInfo.project.name.includes("mobile")) {
await expect(page.locator(".sx-prestart__plan")).toContainText("4개 선택");
}
});
// 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: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다
@ -375,13 +486,16 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
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("인간중심, 라포");
).toContainText("인간중심 · 라포");
// 나머지 기본 목표는 그대로 유지된다.
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
"aria-pressed",
@ -393,10 +507,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
// usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다
test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({
page,
}, testInfo) => {
}) => {
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",
@ -414,12 +529,10 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
);
await expect(
page.locator("details.sx-prestart__settings > summary"),
).toContainText("CBT, 라포 · 탐색");
if (!testInfo.project.name.includes("mobile")) {
await expect(page.locator(".sx-prestart__plan")).toContainText(
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
);
}
).toContainText("CBT · 라포");
await expect(page.locator(".sx-prestart__settings-note")).toContainText(
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
);
});
// usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다
@ -429,8 +542,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
const fixture = await routePrestartFixture(page);
await gotoPrestart(page);
// 기본 선택에서 탐색을 해제해 라포 1개만 목표로 남긴다(자유 선택 검증).
await goalsGroup(page).getByRole("button", { name: /탐색/ }).click();
// 기본 선택인 라포 한 가지 초점으로 시작한다.
await startButton(page).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
@ -465,6 +577,29 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
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,