+
{folderCopy.emptyTitle}
{folderCopy.emptyBody}
diff --git a/frontend/src/components/SearchLayout.live-region.test.tsx b/frontend/src/components/SearchLayout.live-region.test.tsx
new file mode 100644
index 000000000..f199ed6c1
--- /dev/null
+++ b/frontend/src/components/SearchLayout.live-region.test.tsx
@@ -0,0 +1,175 @@
+/* @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" },
+ });
+}
+
+async function flushAsyncWork() {
+ for (let index = 0; index < 5; index += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+}
+
+async function waitForCondition(condition: () => boolean) {
+ for (let index = 0; index < 20; index += 1) {
+ if (condition()) return;
+ await flushAsyncWork();
+ }
+ throw new Error("waitForCondition timed out after 20 attempts");
+}
+
+function searchResult() {
+ return {
+ id: 101,
+ source_message_id: "",
+ subject: "런칭 캠페인 결과",
+ sender: "pm@example.com",
+ date: "2026-05-20T09:00:00Z",
+ snippet: "검색 결과에서 관계 캡처 액션을 실행할 수 있습니다.",
+ thread_id: "thread-launch",
+ reply_count: 2,
+ score: 0.93,
+ };
+}
+
+describe("SearchLayout live-region semantics", () => {
+ 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 search result set as a polite status", async () => {
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] }));
+ if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null }));
+ throw new Error(`Unexpected fetch: ${url}`);
+ }));
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false);
+
+ const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find(
+ (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."),
+ );
+ expect(emptyResultStatus).not.toBeUndefined();
+ expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite");
+ });
+
+ it("announces the empty sender relationship message without wrapping its action button in a status region", async () => {
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [searchResult()] }));
+ if (url.includes("/api/ontology/relationships?")) return Promise.resolve(jsonResponse([]));
+ if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null }));
+ throw new Error(`Unexpected fetch: ${url}`);
+ }));
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false);
+
+ const emptyRelationshipStatus = Array.from(container.querySelectorAll("[role='status']")).find(
+ (node) => node.textContent?.includes("이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다."),
+ );
+ expect(emptyRelationshipStatus).not.toBeUndefined();
+ expect(emptyRelationshipStatus?.querySelector("button, a, input, select, textarea")).toBeNull();
+ });
+
+ it("announces relationship capture failure as an alert", async () => {
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [searchResult()] }));
+ if (url.includes("/api/ontology/relationships?")) return Promise.resolve(jsonResponse([]));
+ if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null }));
+ if (url.endsWith("/api/ontology/relationships/capture-source")) {
+ return Promise.resolve(new Response(JSON.stringify({ error_code: "capture_failed" }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ }));
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ }));
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false);
+
+ const captureButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("발신자 관계 캡처"),
+ );
+ expect(captureButton).not.toBeUndefined();
+
+ await act(async () => {
+ captureButton?.click();
+ });
+ await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처에 실패했습니다.") ?? false);
+
+ const captureAlert = Array.from(container.querySelectorAll("[role='alert']")).find(
+ (node) => node.textContent?.includes("발신자 관계 캡처에 실패했습니다."),
+ );
+ expect(captureAlert).not.toBeUndefined();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx
index f9a47b66c..b88ccfaf8 100644
--- a/frontend/src/components/SearchLayout.test.tsx
+++ b/frontend/src/components/SearchLayout.test.tsx
@@ -84,6 +84,31 @@ describe("SearchLayout product events", () => {
clearRecordedProductEvents();
});
+
+ it("announces an empty search result set as a polite status", async () => {
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] }));
+ if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null }));
+ throw new Error(`Unexpected fetch: ${url}`);
+ }));
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false);
+
+ const emptyResultStatus = Array.from(container.querySelectorAll("[role='status']")).find(
+ (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."),
+ );
+ expect(emptyResultStatus).not.toBeUndefined();
+ expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite");
+ });
+
it("records context search submit, result open, and result action events without raw query text", async () => {
const randomUUID = vi.fn(
() =>
diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx
index 92f16b184..a29b21fc3 100644
--- a/frontend/src/components/SearchLayout.tsx
+++ b/frontend/src/components/SearchLayout.tsx
@@ -208,7 +208,7 @@ function SenderDagPanel({
if (relationships.length === 0) {
return (
-
이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.
+
이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다.
{canCapture ? (
@@ -229,7 +229,7 @@ function SenderDagPanel({
) : null}
{captureStatus === "error" ? (
-
+
발신자 관계 캡처에 실패했습니다.
) : null}
@@ -617,7 +617,7 @@ export function SearchLayout() {
{error}
) : filteredResults.length === 0 ? (
-
+
맥락 검색 결과가 없습니다.
) : (
@@ -1027,4 +1027,4 @@ export function SearchLayout() {
);
-}
+}
\ No newline at end of file