Files
cyclone/src/components/SearchResults.test.tsx
T

300 lines
9.3 KiB
TypeScript

// @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(
<SearchResults
grouped={makeGrouped([], [], [], "")}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
// 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(
<SearchResults
grouped={makeGrouped([], [], [], "nope")}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
expect(container.textContent).toContain("nope");
unmount();
});
it("renders group headers with counts", () => {
const { container, unmount } = renderIntoContainer(
<SearchResults
grouped={makeGrouped(
[claim("CLM-1"), claim("CLM-2")],
[remit("REM-1")]
)}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
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(
<SearchResults
grouped={makeGrouped(
[claim("CLM-1"), claim("CLM-2")],
[remit("REM-1"), remit("REM-2")],
[activity("ACT-1")]
)}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
// 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(
<SearchResults
grouped={makeGrouped([claim("CLM-1"), claim("CLM-2")], [])}
selectedIndex={1}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
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(
<SearchResults
grouped={makeGrouped([c], [])}
selectedIndex={0}
onSelect={onSelect}
onHoverIndex={vi.fn()}
/>
);
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(
<SearchResults
grouped={makeGrouped(
[claim("CLM-1"), claim("CLM-2"), claim("CLM-3")],
[]
)}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={onHover}
/>
);
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(
<SearchResults
ref={ref}
grouped={makeGrouped(
[claim("CLM-1"), claim("CLM-2"), claim("CLM-3")],
[]
)}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
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(
<SearchResults
grouped={makeGrouped(
[claim("CLM-1")],
[],
[]
)}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
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(
<SearchResults
grouped={makeGrouped([claim("CLM-1"), claim("CLM-2")], [])}
selectedIndex={0}
onSelect={vi.fn()}
onHoverIndex={vi.fn()}
/>
);
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();
});
});