diff --git a/canvas/src/components/__tests__/ContextMenu.test.tsx b/canvas/src/components/__tests__/ContextMenu.test.tsx new file mode 100644 index 00000000..9e8cb693 --- /dev/null +++ b/canvas/src/components/__tests__/ContextMenu.test.tsx @@ -0,0 +1,376 @@ +// @vitest-environment jsdom +/** + * Tests for ContextMenu component. + * + * Covers: null guard, node header (name + status), outside-click close, + * Escape close, arrow-key navigation, conditional menu items by status, + * danger items, dividers, rAF position clamping. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ContextMenu } from "../ContextMenu"; +import { useCanvasStore } from "@/store/canvas"; +import { showToast } from "../Toaster"; + +// ─── Mock Toaster ───────────────────────────────────────────────────────────── + +vi.mock("../Toaster", () => ({ + showToast: vi.fn(), +})); + +// ─── Mock API ──────────────────────────────────────────────────────────────── + +const apiPost = vi.fn().mockResolvedValue(undefined as void); +const apiPatch = vi.fn().mockResolvedValue(undefined as void); +vi.mock("@/lib/api", () => ({ + api: { + post: apiPost, + patch: apiPatch, + get: vi.fn(), + }, +})); + +// ─── Mock store ────────────────────────────────────────────────────────────── + +const mockStoreState = { + contextMenu: null as { + x: number; + y: number; + nodeId: string; + nodeData: { + name: string; + status: string; + tier: number; + role: string; + parentId?: string | null; + collapsed?: boolean; + }; + } | null, + closeContextMenu: vi.fn(), + updateNodeData: vi.fn(), + selectNode: vi.fn(), + setPanelTab: vi.fn(), + nestNode: vi.fn().mockResolvedValue(undefined as void), + setPendingDelete: vi.fn(), + setCollapsed: vi.fn(), + arrangeChildren: vi.fn(), + nodes: [] as Array<{ + id: string; + data: { parentId?: string | null }; + }>, +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState), + { getState: () => mockStoreState }, + ), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function openMenu(overrides?: Partial>) { + mockStoreState.contextMenu = { + x: 100, + y: 200, + nodeId: "n1", + nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" }, + ...overrides, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("ContextMenu — visibility", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("renders nothing when contextMenu is null", () => { + mockStoreState.contextMenu = null; + render(); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("renders the menu when contextMenu is set", () => { + openMenu(); + render(); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("has aria-label describing the node name", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByRole("menu").getAttribute("aria-label")).toBe("Actions for Alice"); + }); + + it("shows the node name in the header", () => { + openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }); + render(); + expect(screen.getByText("Bob")).toBeTruthy(); + }); + + it("shows the node status in the header", () => { + openMenu({ nodeData: { name: "Alice", status: "failed", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByText("failed")).toBeTruthy(); + }); +}); + +describe("ContextMenu — close", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("closes when clicking outside the menu", () => { + openMenu(); + render(); + fireEvent.mouseDown(document.body); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); + + it("closes when Escape is pressed", () => { + openMenu(); + render(); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); + + it("closes when Tab is pressed", () => { + openMenu(); + render(); + fireEvent.keyDown(document.body, { key: "Tab" }); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); +}); + +describe("ContextMenu — menu items", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("shows Chat and Terminal only for online nodes", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByRole("menuitem", { name: /chat/i })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy(); + }); + + it("hides Chat and Terminal for offline nodes", () => { + openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }); + render(); + expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull(); + }); + + it("shows Pause for online nodes (not paused)", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + expect(screen.getByRole("menuitem", { name: /pause/i })).toBeTruthy(); + }); + + it("shows Resume for paused nodes (not Pause)", () => { + openMenu({ nodeData: { name: "Carol", status: "paused", tier: 3, role: "writer" } }); + render(); + expect(screen.queryByRole("menuitem", { name: /pause/i })).toBeNull(); + expect(screen.getByRole("menuitem", { name: /resume/i })).toBeTruthy(); + }); + + it("shows Extract from Team only for child nodes", () => { + openMenu({ nodeData: { name: "Child", status: "online", tier: 4, role: "", parentId: "parent1" } }); + render(); + expect(screen.getByRole("menuitem", { name: /extract/i })).toBeTruthy(); + }); + + it("hides Extract from Team for root nodes", () => { + openMenu({ nodeData: { name: "Root", status: "online", tier: 4, role: "", parentId: null } }); + render(); + expect(screen.queryByRole("menuitem", { name: /extract/i })).toBeNull(); + }); + + it("shows team items only when node has children", () => { + openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "" } }); + mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }]; + render(); + expect(screen.getByRole("menuitem", { name: /arrange/i })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /collapse/i })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /zoom/i })).toBeTruthy(); + }); + + it("hides team items when node has no children", () => { + openMenu({ nodeData: { name: "Leaf", status: "online", tier: 4, role: "" } }); + mockStoreState.nodes = []; + render(); + expect(screen.queryByRole("menuitem", { name: /arrange/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /collapse/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /zoom/i })).toBeNull(); + }); + + it("shows Collapse Team when collapsed, Expand Team when expanded", () => { + openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "", collapsed: true } }); + mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }]; + render(); + expect(screen.getByRole("menuitem", { name: /expand/i })).toBeTruthy(); + }); + + it("Delete item has danger styling class", () => { + openMenu(); + render(); + const deleteItem = screen.getByRole("menuitem", { name: /delete/i }); + expect(deleteItem.getAttribute("class")).toMatch(/text-bad|bad/); + }); + + it("renders role=separator for dividers", () => { + openMenu(); + render(); + expect(document.body.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0); + }); +}); + +describe("ContextMenu — keyboard navigation", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("ArrowDown moves focus to next enabled menuitem", () => { + openMenu(); + render(); + const menu = screen.getByRole("menu"); + // First tab goes to Details (first non-disabled item) + fireEvent.keyDown(menu, { key: "ArrowDown" }); + const buttons = screen.getAllByRole("menuitem"); + const focusedIdx = buttons.findIndex((b) => document.activeElement === b); + expect(focusedIdx).toBeGreaterThanOrEqual(0); + }); + + it("ArrowUp moves focus to previous enabled menuitem", () => { + openMenu(); + render(); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + const beforeFocused = document.activeElement; + fireEvent.keyDown(menu, { key: "ArrowUp" }); + // Focus should have moved + expect(document.activeElement).toBeTruthy(); + }); +}); + +describe("ContextMenu — item actions", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.contextMenu = null; + mockStoreState.closeContextMenu.mockClear(); + mockStoreState.updateNodeData.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + mockStoreState.nestNode.mockClear(); + mockStoreState.setPendingDelete.mockClear(); + mockStoreState.setCollapsed.mockClear(); + mockStoreState.arrangeChildren.mockClear(); + mockStoreState.nodes = []; + apiPost.mockReset(); + apiPatch.mockReset(); + vi.mocked(showToast).mockClear(); + }); + + it("Details selects node and opens details tab", () => { + openMenu(); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /details/i })); + expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); + expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details"); + }); + + it("Chat selects node and opens chat tab", () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /chat/i })); + expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); + expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("chat"); + }); + + it("Delete calls setPendingDelete without closing immediately", () => { + openMenu(); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /delete/i })); + expect(mockStoreState.setPendingDelete).toHaveBeenCalled(); + expect(mockStoreState.closeContextMenu).toHaveBeenCalled(); + }); + + it("Pause calls the pause API and updates node status optimistically", async () => { + openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } }); + apiPost.mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /pause/i })); + await act(async () => { /* flush */ }); + expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {}); + expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" }); + }); + + it("Resume calls the resume API", async () => { + openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } }); + apiPost.mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("menuitem", { name: /resume/i })); + await act(async () => { /* flush */ }); + expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {}); + }); +}); diff --git a/canvas/src/components/__tests__/SearchDialog.test.tsx b/canvas/src/components/__tests__/SearchDialog.test.tsx new file mode 100644 index 00000000..2e017707 --- /dev/null +++ b/canvas/src/components/__tests__/SearchDialog.test.tsx @@ -0,0 +1,351 @@ +// @vitest-environment jsdom +/** + * Tests for SearchDialog component. + * + * Covers: renders only when open, Cmd+K/Ctrl+K shortcut, Escape close, + * focus management, text filtering (name/role/status), arrow-key + * navigation, Enter to select, footer count, aria attributes. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchDialog } from "../SearchDialog"; +import { useCanvasStore } from "@/store/canvas"; + +// ─── Mock store ────────────────────────────────────────────────────────────── + +const mockStoreState = { + searchOpen: false, + setSearchOpen: vi.fn((open: boolean) => { + mockStoreState.searchOpen = open; + }), + nodes: [] as Array<{ + id: string; + data: { + name: string; + status: string; + tier: number; + role: string; + parentId?: string | null; + }; + }>, + selectNode: vi.fn(), + setPanelTab: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState), + { getState: () => mockStoreState }, + ), +})); + +const STORAGE_KEY = "molecule-onboarding-complete"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function dispatchKeydown(key: string, meta = false, ctrl = false) { + fireEvent.keyDown(window, { + key, + metaKey: meta, + ctrlKey: ctrl, + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("SearchDialog — visibility", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("does not render when searchOpen is false", () => { + mockStoreState.searchOpen = false; + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders the dialog when searchOpen is true", () => { + mockStoreState.searchOpen = true; + render(); + expect(screen.getByRole("dialog", { name: "Search workspaces" })).toBeTruthy(); + }); +}); + +describe("SearchDialog — keyboard shortcuts", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("opens the dialog when Cmd+K is pressed", () => { + render(); + dispatchKeydown("k", true, false); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true); + }); + + it("opens the dialog when Ctrl+K is pressed", () => { + render(); + dispatchKeydown("k", false, true); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true); + }); + + it("clears the query when Cmd+K opens the dialog", () => { + render(); + dispatchKeydown("k", true, false); + const input = screen.getByRole("combobox"); + expect(input.getAttribute("value") ?? "").toBe(""); + }); + + it("closes the dialog when Escape is pressed while open", () => { + mockStoreState.searchOpen = true; + render(); + dispatchKeydown("Escape"); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false); + }); +}); + +describe("SearchDialog — focus", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("focuses the input when the dialog opens", async () => { + mockStoreState.searchOpen = true; + render(); + await act(async () => { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + expect(document.activeElement?.getAttribute("role")).toBe("combobox"); + }); + + it("input has the combobox role", () => { + mockStoreState.searchOpen = true; + render(); + expect(screen.getByRole("combobox")).toBeTruthy(); + }); +}); + +describe("SearchDialog — filtering", () => { + beforeEach(() => { + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + { id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }, + { id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } }, + ]; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("shows all workspaces when query is empty", () => { + mockStoreState.searchOpen = true; + render(); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Bob")).toBeTruthy(); + expect(screen.getByText("Carol")).toBeTruthy(); + }); + + it("filters workspaces by name (case-insensitive)", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "alice" } }); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.queryByText("Bob")).toBeNull(); + expect(screen.queryByText("Carol")).toBeNull(); + }); + + it("filters workspaces by role (case-insensitive)", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "writer" } }); + expect(screen.queryByText("Alice")).toBeNull(); + expect(screen.queryByText("Bob")).toBeNull(); + expect(screen.getByText("Carol")).toBeTruthy(); + }); + + it("filters workspaces by status", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "online" } }); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.queryByText("Bob")).toBeNull(); + expect(screen.getByText("Carol")).toBeTruthy(); + }); + + it("shows 'No workspaces match' when filtering returns nothing", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "xyz123" } }); + expect(screen.getByText("No workspaces match")).toBeTruthy(); + }); + + it("shows 'No workspaces yet' when canvas is empty", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = []; + render(); + expect(screen.getByText("No workspaces yet")).toBeTruthy(); + }); +}); + +describe("SearchDialog — listbox navigation", () => { + beforeEach(() => { + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + { id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }, + { id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } }, + ]; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("highlights the first result when query is typed", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); + // First result (Alice) should be highlighted + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-selected")).toBe("true"); + }); + + it("ArrowDown moves highlight to the next item", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); // All 3 match + fireEvent.keyDown(input, { key: "ArrowDown" }); + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-selected")).toBe("false"); + expect(options[1].getAttribute("aria-selected")).toBe("true"); + }); + + it("ArrowUp moves highlight to the previous item", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); // All 3 match + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowUp" }); + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-selected")).toBe("true"); + expect(options[1].getAttribute("aria-selected")).toBe("false"); + }); + + it("Enter selects the highlighted workspace", () => { + mockStoreState.searchOpen = true; + render(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "a" } }); // All 3 match + fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob + fireEvent.keyDown(input, { key: "Enter" }); + expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice + expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details"); + expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false); + }); +}); + +describe("SearchDialog — aria attributes", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("dialog has role=dialog and aria-modal=true", () => { + mockStoreState.searchOpen = true; + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-label")).toBe("Search workspaces"); + }); + + it("results container has role=listbox", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + ]; + render(); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("each result has role=option", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + ]; + render(); + expect(screen.getAllByRole("option").length).toBeGreaterThan(0); + }); +}); + +describe("SearchDialog — footer", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockStoreState.searchOpen = false; + mockStoreState.nodes = []; + mockStoreState.setSearchOpen.mockClear(); + mockStoreState.selectNode.mockClear(); + mockStoreState.setPanelTab.mockClear(); + }); + + it("footer shows singular 'workspace' when count is 1", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + ]; + render(); + expect(screen.getByText("1 workspace")).toBeTruthy(); + }); + + it("footer shows plural 'workspaces' when count > 1", () => { + mockStoreState.searchOpen = true; + mockStoreState.nodes = [ + { id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } }, + { id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } }, + ]; + render(); + expect(screen.getByText("2 workspaces")).toBeTruthy(); + }); +});