300 lines
9.3 KiB
TypeScript
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();
|
|
});
|
|
}); |