모바일 메뉴와 주요 화면 UX 개선 및 시각 검수

This commit is contained in:
Yun Chan 2026-09-12 17:57:59 +09:00
parent 89b5093ec7
commit e8b770e4d9
50 changed files with 3189 additions and 457 deletions

View file

@ -329,9 +329,12 @@ function jsonRoute(body: unknown, status = 200) {
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
async function installLearnerHomeFixtures(
page: Page,
options: { detail?: boolean } = {},
options: {
detail?: boolean;
dashboard?: typeof DASHBOARD_BODY;
} = {},
) {
const { detail = true } = options;
const { detail = true, dashboard = DASHBOARD_BODY } = options;
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
@ -339,7 +342,7 @@ async function installLearnerHomeFixtures(
route.fulfill(jsonRoute(SESSIONS_BODY)),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(DASHBOARD_BODY)),
route.fulfill(jsonRoute(dashboard)),
);
if (detail) {
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
@ -383,6 +386,31 @@ async function installStarterFixtures(
}
}
async function installLongPersonaFixtures(page: Page) {
const personas = Array.from({ length: 16 }, (_, index) => {
const number = index + 1;
return {
code: `P${number}`,
display_name: number === 4 ? "박새봄" : `긴 호소 내담자 ${number}`,
difficulty: number === 4 ? "easy" : "hard",
theory_target: ["cbt"],
demographics: { age_band: "30대" },
presenting_summary:
"반복되는 긴 호소 내용으로 인해 실제 목록 카드가 여러 줄로 늘어나는 상황을 검증합니다. 일상과 관계의 어려움을 자세히 설명합니다.",
voice_preset: null,
source: "database",
degraded: false,
};
});
await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(personas)));
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(jsonRoute({ sessions: [], source: "runtime" })),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)),
);
}
test.describe("full-sweep learner home", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
@ -421,17 +449,18 @@ test.describe("full-sweep learner home", () => {
).toBeVisible();
});
// checklist: learner-home-head-status-live
test("announces persona and history counts in the aria-live head status", async ({
// checklist: learner-home-head-action
test("shows the action-focused dashboard header without a global count badge", async ({
page,
}) => {
await signInAsLearner(page);
await page.goto("/learn");
const status = page.locator(".lh-head__status");
await expect(status).toHaveAttribute("aria-live", "polite");
// 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다.
await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 });
await expect(
page.getByRole("heading", { name: "오늘의 상담 연습" }),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-head__status")).toHaveCount(0);
await expect(page.locator("main")).toHaveCount(1);
});
// checklist: learner-home-dash-metric-cards
@ -441,14 +470,15 @@ test.describe("full-sweep learner home", () => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
const cards = page.locator(".lh-dashboard-status");
const cards = page.locator("#lh-panel-growth .lh-dashboard-status");
await expect(cards.getByRole("button")).toHaveCount(4);
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
"4회",
);
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
"3회 종료 · 1회 진행",
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
"상담 완료 3회 · 진행 중 1회",
);
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
"1건",
@ -466,17 +496,51 @@ test.describe("full-sweep learner home", () => {
"성하늘 · 3회",
);
await cards.getByRole("button", { name: /진행 회기/ }).click();
await cards.getByRole("button", { name: /전체 회기/ }).click();
await expect(page).toHaveURL(/\/learn\/history$/);
await page.goto("/learn");
await page.getByRole("tab", { name: "성장 지표" }).click();
await page
.locator(".lh-dashboard-status")
.locator("#lh-panel-growth .lh-dashboard-status")
.getByRole("button", { name: /라포 흐름/ })
.click();
await expect(page).toHaveURL(/\/learn\/practice$/);
});
test("labels all sessions separately from completed counseling and empty ended sessions", async ({
page,
}) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page, {
dashboard: {
...DASHBOARD_BODY,
overview: {
...DASHBOARD_BODY.overview,
total_sessions: 5,
completed_sessions: 2,
active_sessions: 0,
},
},
});
await page.goto("/learn");
await expect(
page.locator("#lh-panel-today .lh-dashboard-status"),
).toHaveCount(0);
await page.getByRole("tab", { name: "성장 지표" }).click();
const allSessions = page
.locator("#lh-panel-growth .lh-dashboard-status")
.getByRole("button", { name: "전체 회기" });
await expect(allSessions).toContainText("5회");
await expect(allSessions).toContainText("상담 완료 2회 · 진행 중 0회");
await expect(allSessions).toContainText("대화 없이 종료한 회기 포함");
await expect(allSessions).toHaveAttribute(
"title",
"전체 회기에는 대화 없이 종료한 회기도 포함합니다.",
);
});
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
test("shows the spotlight recap card and coach CTA for an active session", async ({
page,
@ -547,7 +611,7 @@ test.describe("full-sweep learner home", () => {
const recap = page.getByLabel("마지막 세션 리캡");
await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요");
await expect(recap).toContainText("소속 기관에서 오라고 했어요");
await expect(recap).toContainText("익명 내담자에게 연락처로 연락해 볼까요");
await expect(recap).toContainText("익명 인물에게 연락처로 연락해 볼까요");
for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) {
await expect(recap).not.toContainText(token);
}
@ -946,4 +1010,210 @@ test.describe("full-sweep learner home", () => {
listbox.getByRole("option", { name: /P4/ }),
).toHaveAttribute("aria-selected", "false");
});
test("keeps the initially selected P4 visible inside the mobile persona list", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installStarterFixtures(page);
await page.setViewportSize({ width: 320, height: 844 });
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
const selected = listbox.getByRole("option", { name: /P4/ });
await expect(selected).toHaveAttribute("aria-selected", "true");
await expect.poll(() => page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".lh-personas");
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
if (!list || !option) return false;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const subpixelTolerance = 1;
return (
optionRect.left >= listRect.left - subpixelTolerance &&
optionRect.right <= listRect.right + subpixelTolerance &&
optionRect.top >= listRect.top - subpixelTolerance &&
optionRect.bottom <= listRect.bottom + subpixelTolerance
);
})).toBe(true);
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
await page.screenshot({
path: testInfo.outputPath("p4-initial-mobile-320.png"),
fullPage: false,
animations: "disabled",
});
});
test("keeps the selected persona visible after a mobile viewport resize", async ({ page }) => {
await signInAsLearner(page);
await installStarterFixtures(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
const selected = listbox.getByRole("option", { name: /P4/ });
await expect(selected).toHaveAttribute("aria-selected", "true");
await page.setViewportSize({ width: 320, height: 844 });
await expect.poll(() => page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".lh-personas");
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
if (!list || !option) return false;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const subpixelTolerance = 1;
return (
optionRect.left >= listRect.left - subpixelTolerance &&
optionRect.right <= listRect.right + subpixelTolerance &&
optionRect.top >= listRect.top - subpixelTolerance &&
optionRect.bottom <= listRect.bottom + subpixelTolerance
);
})).toBe(true);
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
});
test("contains long persona lists without pushing the selected practice CTA below the list", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installLongPersonaFixtures(page);
for (const width of [768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await page.goto("/learn/practice");
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
const selected = listbox.getByRole("option", { name: /P4/ });
const longPersona = listbox.getByRole("option", { name: /P12/ });
const lastPersona = listbox.getByRole("option", { name: /P16/ });
const startCta = page.getByRole("button", { name: "완전히 새로 시작" });
await expect(selected).toHaveAttribute("aria-selected", "true");
await expect(longPersona).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".lh-personas");
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
if (!list || !option) return false;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
const options = [...list.querySelectorAll<HTMLElement>("[role='option']")];
const rowsDoNotOverlap = options.every((current, index) => {
const currentRect = current.getBoundingClientRect();
return options.slice(index + 1).every((other) => {
const otherRect = other.getBoundingClientRect();
return (
currentRect.right <= otherRect.left ||
otherRect.right <= currentRect.left ||
currentRect.bottom <= otherRect.top ||
otherRect.bottom <= currentRect.top
);
});
});
const contentFits = options.every((current) => {
const content = current.querySelector<HTMLElement>(".lh-persona__body");
return !content || content.getBoundingClientRect().bottom <= current.getBoundingClientRect().bottom;
});
const hit = document.elementFromPoint(
optionRect.left + optionRect.width / 2,
optionRect.top + optionRect.height / 2,
);
return (
list.scrollHeight > list.clientHeight &&
getComputedStyle(list).overflowY === "auto" &&
optionRect.top >= listRect.top &&
optionRect.bottom <= listRect.bottom &&
rowsDoNotOverlap &&
contentFits &&
!!hit && option.contains(hit)
);
})).toBe(true);
await expect(startCta).toBeInViewport();
await page.screenshot({
path: testInfo.outputPath(`long-personas-w${width}-top.png`),
fullPage: false,
animations: "disabled",
});
await longPersona.scrollIntoViewIfNeeded();
await expect(longPersona).toBeInViewport();
await listbox.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect(lastPersona).toBeInViewport();
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
await page.screenshot({
path: testInfo.outputPath(`long-personas-w${width}-bottom.png`),
fullPage: false,
animations: "disabled",
});
}
});
test("opens zero-record persona rows and keeps the compact history overview two columns", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installStarterFixtures(page);
for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 844 });
await page.goto("/learn/history");
const overview = page.locator(".lh-history-overview");
const practiceCta = page.getByRole("button", { name: "상담 연습으로" });
const toggle = page.getByRole("button", { name: /미진행 내담자 2명 보기/ });
await expect(page.getByText("아직 연습 기록이 없습니다.")).toBeVisible();
await expect(practiceCta).toBeVisible();
await expect(toggle).toBeVisible();
const columns = await overview.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length);
expect(columns).toBe(2);
for (const target of [practiceCta, toggle]) {
const height = await target.evaluate((element) => element.getBoundingClientRect().height);
expect(height).toBeGreaterThanOrEqual(44);
}
await toggle.scrollIntoViewIfNeeded();
await page.locator(".lh-persona-progress").screenshot({
path: testInfo.outputPath(`history-empty-bottom-mobile-${width}.png`),
animations: "disabled",
});
await toggle.click();
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(2);
await page.getByRole("button", { name: "미진행 내담자 숨기기" }).click();
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(0);
}
});
test("keeps history filters and session actions usable at mobile widths", async ({ page }, testInfo) => {
await signInAsLearner(page);
await installLearnerHomeFixtures(page);
for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 844 });
await page.goto("/learn/history");
const filters = page.locator(".lh-history-filter button");
const historyActions = page.locator(".lh-session-actions button");
await expect(filters).toHaveCount(4);
await expect(historyActions).not.toHaveCount(0);
for (const target of [filters, historyActions]) {
const boxes = await target.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { height: rect.height, width: rect.width };
}),
);
expect(boxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
if (width === 320) {
await page.screenshot({
path: testInfo.outputPath("history-top-mobile-320.png"),
fullPage: false,
animations: "disabled",
});
await historyActions.first().locator("..").locator("..").screenshot({
path: testInfo.outputPath("history-action-row-mobile-320.png"),
animations: "disabled",
});
}
await page.goto("/learn/practice");
const practiceActions = page.locator(".lh-session-actions button");
await expect(practiceActions).not.toHaveCount(0);
const practiceBoxes = await practiceActions.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { height: rect.height, width: rect.width };
}),
);
expect(practiceBoxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
});
});