feat(frontend): global Cmd-K search across claims/remits/activity
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user