Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3cb31d6
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
7e221bb
test(search): pin live-region interaction boundary
seonghobae Sep 6, 2026
84a7659
fix(search): isolate live status from interactive controls
seonghobae Sep 6, 2026
69c92ec
chore(a11y): restore canonical Palette guidance
seonghobae Sep 6, 2026
a0c544b
chore(a11y): exactly adopt protected Palette guidance
seonghobae Sep 6, 2026
597790b
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
bb8209b
fix(a11y): restore reviewed live-region contract
seonghobae Sep 6, 2026
13e3f18
test(a11y): cover empty email live region
seonghobae Sep 6, 2026
3f41ff5
test(a11y): cover empty search live region
seonghobae Sep 6, 2026
dfa3f03
fix(a11y): narrow empty-state announcement scope
seonghobae Sep 6, 2026
6bd0ec5
test(a11y): match inbox empty-state copy
seonghobae Sep 6, 2026
84cf3c9
test(a11y): distinguish inbox and search empty states
seonghobae Sep 6, 2026
65c370d
fix(a11y): distinguish inbox and search empty copy
seonghobae Sep 6, 2026
b243f7c
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
4d2c0cb
fix(a11y): preserve reviewed empty-state contracts
seonghobae Sep 6, 2026
98f7c66
test(mail): preserve latest result set across search races
seonghobae Sep 6, 2026
05423b5
fix(mail): ignore stale search responses after scope changes
seonghobae Sep 6, 2026
134813f
test(mail): make stale-response ordering contract deterministic
seonghobae Sep 6, 2026
a2810ed
test(a11y): exercise mail and search live regions in browser
seonghobae Sep 6, 2026
270acbc
test(a11y): make search-detail browser probe deterministic
seonghobae Sep 6, 2026
33f1e14
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae Sep 6, 2026
58df9fb
fix(a11y): preserve reviewed mail search contracts after concurrent c…
seonghobae Sep 6, 2026
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
138 changes: 138 additions & 0 deletions frontend/src/components/EmailList.request-ordering.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/* @vitest-environment jsdom */
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";

import { EmailList } from "./EmailList";

function jsonResponse(body: unknown) {
return {
ok: true,
json: async () => body,
};
}

async function flushAsyncWork() {
for (let index = 0; index < 5; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}

function setInputValue(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}

describe("EmailList request ordering", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;

afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});

it("keeps cleared inbox results when an older search completes later", async () => {
let resolveSearch: ((value: ReturnType<typeof jsonResponse>) => void) | null = null;
let resolveInboxRefresh: ((value: ReturnType<typeof jsonResponse>) => void) | null = null;
let inboxCalls = 0;

const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/search")) {
return new Promise((resolve) => {
resolveSearch = resolve;
});
}
if (url.endsWith("/api/emails")) {
inboxCalls += 1;
if (inboxCalls === 1) {
return Promise.resolve(jsonResponse({ emails: [] }));
}
return new Promise((resolve) => {
resolveInboxRefresh = resolve;
});
}
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<EmailList onSelectEmail={vi.fn()} selectedEmailId={null} />);
});
await flushAsyncWork();

const input = container.querySelector<HTMLInputElement>("#email-search");
const form = input?.closest("form");
expect(input).not.toBeNull();
expect(form).not.toBeNull();

await act(async () => {
setInputValue(input as HTMLInputElement, "계약");
});
await act(async () => {
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});

expect(resolveSearch).not.toBeNull();

const clearButton = container.querySelector<HTMLButtonElement>('button[aria-label="맥락 검색어 지우기"]');
expect(clearButton).not.toBeNull();

await act(async () => {
clearButton?.click();
});

expect(resolveInboxRefresh).not.toBeNull();

await act(async () => {
resolveInboxRefresh?.(jsonResponse({
emails: [
{
id: 41,
sender: "운영팀",
subject: "최신 받은편지함",
snippet: "검색 해제 뒤 표시해야 하는 받은 메일입니다.",
},
],
}));
});
await flushAsyncWork();

expect(container.textContent).toContain("최신 받은편지함");

await act(async () => {
resolveSearch?.(jsonResponse({
results: [
{
id: 99,
sender: "검색 인덱스",
subject: "늦게 도착한 검색 결과",
snippet: "더 오래된 검색 요청의 응답입니다.",
},
],
}));
});
await flushAsyncWork();

expect(input?.value).toBe("");
expect(container.textContent).toContain("최신 받은편지함");
expect(container.textContent).not.toContain("늦게 도착한 검색 결과");
});
});
33 changes: 31 additions & 2 deletions frontend/src/components/EmailList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ describe("EmailList", () => {
expect(selectedThread?.className).toContain("min-h-20");
});

it("announces an empty inbox through exactly one polite status region", async () => {
const fetchMock = vi.fn(() => Promise.resolve(jsonResponse({ emails: [] })));
vi.stubGlobal("fetch", fetchMock);

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<EmailList onSelectEmail={vi.fn()} selectedEmailId={null} />);
});
await flushAsyncWork();

const emptyStatuses = Array.from(container.querySelectorAll<HTMLElement>('[role="status"]')).filter(
(node) => node.textContent?.includes("받은 메일이 없습니다"),
);
expect(emptyStatuses).toHaveLength(1);
expect(emptyStatuses[0]?.getAttribute("aria-live")).toBe("polite");
expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다");
});

it("uses the missing-title fallback for blank email subjects", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
Expand Down Expand Up @@ -121,7 +142,7 @@ describe("EmailList", () => {
expect(container.textContent).toContain("(제목 없음)");
});

it("shows search loading feedback and clears the query back to inbox results", async () => {
it("shows search loading feedback, distinguishes search-empty copy, and clears back to inbox results", async () => {
let resolveSearch: ((value: ReturnType<typeof jsonResponse>) => void) | null = null;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
Expand Down Expand Up @@ -149,10 +170,14 @@ describe("EmailList", () => {
expect(input).not.toBeNull();
expect(form).not.toBeNull();
expect(submitButton).not.toBeNull();
expect(container.textContent).toContain("받은 메일이 없습니다");

await act(async () => {
setInputValue(input as HTMLInputElement, "계약");
});
expect(container.textContent).toContain("받은 메일이 없습니다");
expect(container.textContent).not.toContain("맥락 검색 결과가 없습니다");

const clearButton = container.querySelector<HTMLButtonElement>('button[aria-label="맥락 검색어 지우기"]');
expect(clearButton).not.toBeNull();

Expand All @@ -175,13 +200,17 @@ describe("EmailList", () => {
});
await flushAsyncWork();

expect(container.textContent).toContain("맥락 검색 결과가 없습니다");
expect(container.textContent).not.toContain("받은 메일이 없습니다");

await act(async () => {
clearButton?.click();
});
await flushAsyncWork();

expect(input?.value).toBe("");
expect(fetchMock).toHaveBeenLastCalledWith("/api/emails", expect.any(Object));
expect(container.textContent).toContain("받은 메일이 없습니다");
});

it("renders sent mail reply tracking mode from the sent folder API", async () => {
Expand Down Expand Up @@ -271,4 +300,4 @@ describe("EmailList", () => {
expect(container.textContent).not.toContain("alert(1)");
expect(container.textContent).not.toContain("alert(2)");
});
});
});
50 changes: 34 additions & 16 deletions frontend/src/components/EmailList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,32 @@ export function EmailList({
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [emptyStateMode, setEmptyStateMode] = useState<'folder' | 'search'>('folder');
const searchInputRef = useRef<HTMLInputElement>(null);
const requestSequenceRef = useRef(0);

const fetchEmails = useCallback(async (query = "") => {
const normalizedQuery = query.trim();
const requestSequence = requestSequenceRef.current + 1;
requestSequenceRef.current = requestSequence;
setEmptyStateMode(normalizedQuery === "" ? 'folder' : 'search');
setLoading(true);
setError(null);
setIsSearching(normalizedQuery !== "");
try {
if (query.trim() === "") {
setEmails(await fetchFolderEmails(folder));
} else {
setIsSearching(true);
const data = await apiClient.post<{ results: EmailItem[] }>('/api/search', { query });
setEmails(data.results || []);
}
const nextEmails = normalizedQuery === ""
? await fetchFolderEmails(folder)
: (await apiClient.post<{ results: EmailItem[] }>('/api/search', { query })).results || [];
if (requestSequence !== requestSequenceRef.current) return;
setEmails(nextEmails);
} catch (err: unknown) {
if (requestSequence !== requestSequenceRef.current) return;
setError(err instanceof Error ? err.message : "Failed to load emails");
} finally {
setLoading(false);
setIsSearching(false);
if (requestSequence === requestSequenceRef.current) {
setLoading(false);
setIsSearching(false);
}
}
}, [folder]);

Expand All @@ -158,8 +166,6 @@ export function EmailList({
secondaryBadge: '지식 정리',
focusLabel: '보낸 메일 추적',
focusText: '응답 대기 스레드와 self-sent 지식 후보를 표시합니다',
emptyTitle: '보낸 메일이 없습니다',
emptyBody: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.',
}
: {
title: '받은편지함',
Expand All @@ -168,9 +174,21 @@ export function EmailList({
secondaryBadge: '실행 항목',
focusLabel: '오늘의 판단 포인트',
focusText: '메일 데이터 기반으로 판단 포인트를 표시합니다',
emptyTitle: '맥락 검색 결과가 없습니다',
emptyBody: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.',
};
const emptyCopy = emptyStateMode === 'search'
? {
title: '맥락 검색 결과가 없습니다',
body: '맥락 검색어를 바꾸거나 메일 동기화 상태를 확인하세요.',
}
: folder === 'sent'
? {
title: '보낸 메일이 없습니다',
body: 'SMTP/IMAP 동기화 후 보낸 스레드와 답변 대기 상태가 표시됩니다.',
}
: {
title: '받은 메일이 없습니다',
body: '메일 동기화 후 받은 스레드가 표시됩니다.',
};
const searchBusy = isSearching || loading;

return (
Expand Down Expand Up @@ -254,9 +272,9 @@ export function EmailList({
) : error ? (
<div role="alert" className="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-600">{error}</div>
) : emails.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-background/70 p-5 text-sm text-muted-foreground">
<p className="font-bold text-foreground">{folderCopy.emptyTitle}</p>
<p className="mt-1 text-xs leading-5">{folderCopy.emptyBody}</p>
<div role="status" aria-live="polite" className="rounded-2xl border border-dashed border-border bg-background/70 p-5 text-sm text-muted-foreground">
<p className="font-bold text-foreground">{emptyCopy.title}</p>
<p className="mt-1 text-xs leading-5">{emptyCopy.body}</p>
</div>
) : (
emails.map((email: EmailItem) => (
Expand Down
Loading
Loading