모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue