// @vitest-environment happy-dom // SearchResults is pure presentation — no React Query, no router. // Mirror the IS_REACT_ACT_ENVIRONMENT flag from the other component // tests so React doesn't log act() warnings during render/unmount. (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; import { SearchResults } from "./SearchResults"; import type { SearchResult, SearchResultsGrouped } from "@/hooks/useSearch"; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- function makeGrouped( claims: SearchResult[], remits: SearchResult[], activity: SearchResult[] = [], query = "ngu" ): SearchResultsGrouped { return { claims, remittances: remits, activity, total: claims.length + remits.length + activity.length, query, }; } function claim(id: string, title = `Claim ${id}`): SearchResult { return { kind: "claim", id, title, subtitle: "subtitle", badge: "paid", score: 100, }; } function remit(id: string): SearchResult { return { kind: "remittance", id, title: `Remit ${id}`, subtitle: "subtitle", badge: "received", score: 50, }; } function activity(id: string, kind = "claim_paid"): SearchResult { return { kind: "activity", id, title: `Activity ${id}`, subtitle: "subtitle", badge: kind, score: 25, }; } // --------------------------------------------------------------------------- // Render helpers // --------------------------------------------------------------------------- function renderIntoContainer(element: React.ReactElement): { container: HTMLDivElement; unmount: () => void; } { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); act(() => { root.render(element); }); return { container, unmount: () => { act(() => root.unmount()); container.remove(); }, }; } /** Find a result row by its data-result-index attribute (0-based). */ function rowAt(parent: HTMLElement, index: number): HTMLElement | null { return parent.querySelector(`[data-result-index="${index}"]`); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("SearchResults", () => { it("renders the empty state when there are no results", () => { const { container, unmount } = renderIntoContainer( ); // The listbox role is always present so screen readers can find // it; only the empty state's text differentiates "no results". expect(container.querySelector('[role="listbox"]')).not.toBeNull(); expect(container.textContent).toContain("Type to search"); unmount(); }); it("renders the query in the empty state when results are empty but a query exists", () => { const { container, unmount } = renderIntoContainer( ); expect(container.textContent).toContain("nope"); unmount(); }); it("renders group headers with counts", () => { const { container, unmount } = renderIntoContainer( ); expect(container.textContent).toContain("Claims · 2"); expect(container.textContent).toContain("Remittances · 1"); unmount(); }); it("renders one row per result, in flat display order", () => { const { container, unmount } = renderIntoContainer( ); // 5 rows total, indexed 0..4, with kinds in order: claim, claim, // remit, remit, activity. expect(rowAt(container, 0)?.getAttribute("data-result-kind")).toBe("claim"); expect(rowAt(container, 1)?.getAttribute("data-result-kind")).toBe("claim"); expect(rowAt(container, 2)?.getAttribute("data-result-kind")).toBe("remittance"); expect(rowAt(container, 3)?.getAttribute("data-result-kind")).toBe("remittance"); expect(rowAt(container, 4)?.getAttribute("data-result-kind")).toBe("activity"); // The data-result-id should round-trip. expect(rowAt(container, 0)?.getAttribute("data-result-id")).toBe("CLM-1"); expect(rowAt(container, 4)?.getAttribute("data-result-id")).toBe("ACT-1"); unmount(); }); it("marks the selected row with data-selected", () => { const { container, unmount } = renderIntoContainer( ); expect(rowAt(container, 0)?.hasAttribute("data-selected")).toBe(false); expect(rowAt(container, 1)?.hasAttribute("data-selected")).toBe(true); unmount(); }); it("calls onSelect with the clicked result on mousedown", () => { const onSelect = vi.fn(); const c = claim("CLM-99"); const { container, unmount } = renderIntoContainer( ); const row = rowAt(container, 0)!; expect(row).not.toBeNull(); act(() => { row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); }); expect(onSelect).toHaveBeenCalledWith(c); unmount(); }); it("calls onHoverIndex with the row index on mouseenter", () => { const onHover = vi.fn(); const { container, unmount } = renderIntoContainer( ); act(() => { // React's synthetic `onMouseEnter` is implemented on top of the // native `mouseover` event (which DOES bubble) — the native // `mouseenter` event does NOT bubble, so dispatching it here // would never reach React's listener. `mouseover` with // bubbles:true is the canonical way to trigger React's // `onMouseEnter` in a unit test. rowAt(container, 2)!.dispatchEvent( new MouseEvent("mouseover", { bubbles: true }) ); }); expect(onHover).toHaveBeenCalledWith(2); unmount(); }); it("exposes a focusIndex imperative handle that scrolls the matching row into view", () => { // Replace scrollIntoView on the prototype so we can spy on it // without a real scrollable container. happy-dom doesn't actually // lay out the page so the call would be a no-op otherwise. const scrollSpy = vi.fn(); const proto = HTMLElement.prototype as unknown as { scrollIntoView: (opts?: ScrollIntoViewOptions) => void; }; const original = proto.scrollIntoView; proto.scrollIntoView = scrollSpy; try { const ref = React.createRef<{ focusIndex: (i: number) => void }>(); const { unmount } = renderIntoContainer( ); act(() => { ref.current?.focusIndex(2); }); expect(scrollSpy).toHaveBeenCalled(); unmount(); } finally { proto.scrollIntoView = original; } }); it("only renders groups that have at least one result", () => { const { container, unmount } = renderIntoContainer( ); expect(container.textContent).toContain("Claims · 1"); expect(container.textContent).not.toContain("Remittances"); expect(container.textContent).not.toContain("Activity ·"); unmount(); }); it("renders rows with role=option and aria-selected", () => { const { container, unmount } = renderIntoContainer( ); const rows = container.querySelectorAll('[role="option"]'); expect(rows.length).toBe(2); expect(rows[0]!.getAttribute("aria-selected")).toBe("true"); expect(rows[1]!.getAttribute("aria-selected")).toBe("false"); unmount(); }); });