diff --git a/frontend/src/components/SearchLayout.empty-live-region.test.tsx b/frontend/src/components/SearchLayout.empty-live-region.test.tsx new file mode 100644 index 000000000..0578e46be --- /dev/null +++ b/frontend/src/components/SearchLayout.empty-live-region.test.tsx @@ -0,0 +1,131 @@ +/* @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("next/dynamic", () => ({ + default: () => function MockDynamic() { + return
mock graph
; + }, +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} + ), +})); + +vi.mock("lucide-react", () => ({ + AlertCircle: () => , + CalendarDays: () => , + CheckCircle2: () => , + Clock: () => , + CornerDownRight: () => , + FileText: () => , + Loader2: () => , + Mail: () => , + Network: () => , + Search: () => , + Sparkles: () => , + X: () => , +})); + +import { SearchLayout } from "./SearchLayout"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +type DeferredJsonResponse = { + responsePromise: Promise; + bodyPromise: Promise; + resolveResponse: () => void; + resolveBody: (body: unknown) => void; +}; + +function deferredJsonResponse(): DeferredJsonResponse { + let resolveResponsePromise!: (response: Response) => void; + let resolveBodyPromise!: (body: unknown) => void; + const bodyPromise = new Promise((resolve) => { + resolveBodyPromise = resolve; + }); + const responsePromise = new Promise((resolve) => { + resolveResponsePromise = resolve; + }); + const response = new Response(null, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + vi.spyOn(response, "json").mockImplementation(() => bodyPromise); + + return { + responsePromise, + 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("SearchLayout empty-result live region", () => { + 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("announces an empty result only after the asynchronous response body leaves loading", async () => { + const searchResponse = deferredJsonResponse(); + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/search/answer")) { + return Promise.resolve(jsonResponse({ answer: null, citations: [], provenance: null })); + } + if (url.endsWith("/api/search")) return searchResponse.responsePromise; + throw new Error(`Unexpected fetch: ${url}`); + })); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(statusWithText(container, "맥락 검색 결과를 불러오는 중입니다.")).not.toBeNull(); + expect(statusWithText(container, "맥락 검색 결과가 없습니다.")).toBeNull(); + + await act(async () => { + searchResponse.resolveResponse(); + await searchResponse.responsePromise; + }); + + expect(statusWithText(container, "맥락 검색 결과를 불러오는 중입니다.")).not.toBeNull(); + expect(statusWithText(container, "맥락 검색 결과가 없습니다.")).toBeNull(); + + await act(async () => { + searchResponse.resolveBody({ results: [] }); + await searchResponse.bodyPromise; + }); + + const emptyStatus = statusWithText(container, "맥락 검색 결과가 없습니다."); + expect(emptyStatus).not.toBeNull(); + expect(emptyStatus?.getAttribute("aria-live")).toBe("polite"); + expect(container.textContent).not.toContain("맥락 검색 결과를 불러오는 중입니다."); + }); +}); diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 5cf5ad72f..41a19a915 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -654,7 +654,7 @@ export function SearchLayout() { {error} ) : filteredResults.length === 0 ? ( -
+
맥락 검색 결과가 없습니다.
) : (