diff --git a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx new file mode 100644 index 000000000..f590964d9 --- /dev/null +++ b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx @@ -0,0 +1,173 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/EmailList", () => ({ + EmailList: () =>
mock email list
, +})); + +vi.mock("@/components/EmailDetail", () => ({ + EmailDetail: () =>
mock email detail
, +})); + +vi.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: React.ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); + +vi.mock("@/components/mobile-workspace-panels", () => ({ + MobileCalendarPanel: () =>
mock calendar
, + MobileSearchPanel: () =>
mock search
, +})); + +vi.mock("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Inbox: () => , + Network: () => , + Send: () => , + Settings: () => , + Sparkles: () => , +})); + +import { WorkspaceHome } from "./WorkspaceHome"; + +type DeferredResponse = { + promise: Promise<{ ok: true; json: () => Promise }>; + bodyPromise: Promise; + resolveResponse: () => void; + resolveBody: (body: unknown) => void; +}; + +function deferredResponse(): DeferredResponse { + let resolveResponsePromise!: (response: { ok: true; json: () => Promise }) => void; + let resolveBodyPromise!: (body: unknown) => void; + const bodyPromise = new Promise((resolve) => { + resolveBodyPromise = resolve; + }); + const promise = new Promise<{ ok: true; json: () => Promise }>((resolve) => { + resolveResponsePromise = resolve; + }); + const response = { ok: true as const, json: () => bodyPromise }; + + return { + promise, + bodyPromise, + resolveResponse: () => resolveResponsePromise(response), + resolveBody: resolveBodyPromise, + }; +} + +function statusWithText(container: HTMLElement, text: string) { + return Array.from(container.querySelectorAll('[role="status"]')).find( + (element) => element.textContent?.includes(text), + ) ?? null; +} + +describe("WorkspaceHome empty-state live regions", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + localStorage.clear(); + vi.unstubAllGlobals(); + }); + + it("announces primary dashboard empty states only after asynchronous bodies leave loading", async () => { + vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + + const emailsResponse = deferredResponse(); + const pendingRepliesResponse = deferredResponse(); + const tasksResponse = deferredResponse(); + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/emails/pending-replies?limit=3")) return pendingRepliesResponse.promise; + if (url.endsWith("/api/emails")) return emailsResponse.promise; + if (url.endsWith("/api/tasks")) return tasksResponse.promise; + if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) { + return Promise.resolve({ ok: true, json: async () => [] }); + } + if (url.endsWith("/api/search")) { + return Promise.resolve({ ok: true, json: async () => ({ results: [] }) }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(container.textContent).toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).toContain("작업을 불러오는 중..."); + expect(container.textContent).toContain("메일을 불러오는 중..."); + + await act(async () => { + emailsResponse.resolveResponse(); + pendingRepliesResponse.resolveResponse(); + tasksResponse.resolveResponse(); + await Promise.all([ + emailsResponse.promise, + pendingRepliesResponse.promise, + tasksResponse.promise, + ]); + }); + + expect(container.textContent).toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).toContain("작업을 불러오는 중..."); + expect(container.textContent).toContain("메일을 불러오는 중..."); + for (const copy of [ + "답변 대기 중인 보낸 메일이 없습니다.", + "대기 작업이 없습니다.", + "수신된 메일이 없습니다.", + ]) { + expect(statusWithText(container, copy), copy).toBeNull(); + } + + await act(async () => { + emailsResponse.resolveBody({ emails: [] }); + pendingRepliesResponse.resolveBody({ emails: [] }); + tasksResponse.resolveBody([]); + await Promise.all([ + emailsResponse.bodyPromise, + pendingRepliesResponse.bodyPromise, + tasksResponse.bodyPromise, + ]); + }); + + for (const copy of [ + "답변 대기 중인 보낸 메일이 없습니다.", + "대기 작업이 없습니다.", + "수신된 메일이 없습니다.", + ]) { + const emptyStatus = statusWithText(container, copy); + expect(emptyStatus, copy).not.toBeNull(); + expect(emptyStatus?.getAttribute("aria-live"), copy).toBe("polite"); + } + + expect(container.textContent).not.toContain("답변 대기 메일을 불러오는 중..."); + expect(container.textContent).not.toContain("작업을 불러오는 중..."); + expect(container.textContent).not.toContain("메일을 불러오는 중..."); + }); +}); diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx index e0c97d49e..9ca5619a0 100644 --- a/frontend/src/components/WorkspaceHome.tsx +++ b/frontend/src/components/WorkspaceHome.tsx @@ -610,7 +610,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV ) : pendingReplyUnavailable ? (
답변 대기 메일을 확인하지 못했습니다.
) : pendingReplies.length === 0 ? ( -
답변 대기 중인 보낸 메일이 없습니다.
+
답변 대기 중인 보낸 메일이 없습니다.
) : pendingReplies.map((reply) => { const safeSubject = toSafeReactText(reply.subject?.trim() || null, '(제목 없음)'); const safeSnippet = toSafeReactText(reply.snippet); @@ -641,7 +641,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV ) : taskUnavailable ? (
작업 현황을 확인하지 못했습니다.
) : pendingTasks.length === 0 ? ( -
대기 작업이 없습니다.
+
대기 작업이 없습니다.
) : pendingTasks.slice(0, 3).map((task) => { const pKor = mapPriorityToKorean(task.priority); const pClass = pKor === '긴급' || pKor === '높음' ? 'text-red-500' : pKor === '보통' ? 'text-green-500' : 'text-muted-foreground'; @@ -744,7 +744,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV ) : emailUnavailable ? (
최근 메일을 확인하지 못했습니다.
) : emails.length === 0 ? ( -
수신된 메일이 없습니다.
+
수신된 메일이 없습니다.
) : emails.slice(0, 5).map((mail) => (