Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions frontend/src/components/WorkspaceHome.empty-live-region.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <section aria-label="mock email list">mock email list</section>,
}));

vi.mock("@/components/EmailDetail", () => ({
EmailDetail: () => <section aria-label="mock email detail">mock email detail</section>,
}));

vi.mock("@/components/ui/resizable", () => ({
ResizablePanelGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ResizablePanel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ResizableHandle: () => <div />,
}));

vi.mock("@/components/mobile-workspace-panels", () => ({
MobileCalendarPanel: () => <section>mock calendar</section>,
MobileSearchPanel: () => <section>mock search</section>,
}));

vi.mock("next/dynamic", () => ({
default: () => function MockDynamic() {
return <div>mock graph</div>;
},
}));

vi.mock("lucide-react", () => ({
CalendarDays: () => <svg aria-hidden="true" />,
CheckCircle2: () => <svg aria-hidden="true" />,
Inbox: () => <svg aria-hidden="true" />,
Network: () => <svg aria-hidden="true" />,
Send: () => <svg aria-hidden="true" />,
Settings: () => <svg aria-hidden="true" />,
Sparkles: () => <svg aria-hidden="true" />,
}));

import { WorkspaceHome } from "./WorkspaceHome";

type DeferredResponse = {
promise: Promise<{ ok: true; json: () => Promise<unknown> }>;
bodyPromise: Promise<unknown>;
resolveResponse: () => void;
resolveBody: (body: unknown) => void;
};

function deferredResponse(): DeferredResponse {
let resolveResponsePromise!: (response: { ok: true; json: () => Promise<unknown> }) => void;
let resolveBodyPromise!: (body: unknown) => void;
const bodyPromise = new Promise<unknown>((resolve) => {
resolveBodyPromise = resolve;
});
const promise = new Promise<{ ok: true; json: () => Promise<unknown> }>((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<HTMLElement>('[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(<WorkspaceHome forcedStartupView="dashboard" />);
});

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("메일을 불러오는 중...");
});
});
6 changes: 3 additions & 3 deletions frontend/src/components/WorkspaceHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
) : pendingReplyUnavailable ? (
<div className="text-sm text-muted-foreground p-2">답변 대기 메일을 확인하지 못했습니다.</div>
) : pendingReplies.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">답변 대기 중인 보낸 메일이 없습니다.</div>
<div role="status" aria-live="polite" className="text-sm text-muted-foreground p-2">답변 대기 중인 보낸 메일이 없습니다.</div>
) : pendingReplies.map((reply) => {
const safeSubject = toSafeReactText(reply.subject?.trim() || null, '(제목 없음)');
const safeSnippet = toSafeReactText(reply.snippet);
Expand Down Expand Up @@ -641,7 +641,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
) : taskUnavailable ? (
<div className="text-sm text-muted-foreground p-2">작업 현황을 확인하지 못했습니다.</div>
) : pendingTasks.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">대기 작업이 없습니다.</div>
<div role="status" aria-live="polite" className="text-sm text-muted-foreground p-2">대기 작업이 없습니다.</div>
) : 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';
Expand Down Expand Up @@ -744,7 +744,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
) : emailUnavailable ? (
<div className="text-sm text-muted-foreground p-2">최근 메일을 확인하지 못했습니다.</div>
) : emails.length === 0 ? (
<div className="text-sm text-muted-foreground p-2">수신된 메일이 없습니다.</div>
<div role="status" aria-live="polite" className="text-sm text-muted-foreground p-2">수신된 메일이 없습니다.</div>
) : emails.slice(0, 5).map((mail) => (
<div key={mail.id} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
Expand Down