ec11c9dc76
Keyboard focus was invisible on the objects-table sort headers and
page-size select, breadcrumb links, the external-URI link, and the
combobox input/clear/trigger. Apply the shared focusRing helper in app
code and the kit's inline focus-visible classes (matching input.tsx)
in ui/combobox.
Make the search result count a role="status" live region so screen
readers announce updated counts while typing; the existing search test
now asserts the count through getByRole("status").
Closes #69
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
5.5 KiB
TypeScript
138 lines
5.5 KiB
TypeScript
import { expect, test } from "vitest";
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { http, HttpResponse } from "msw";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { createMemoryRouter, Route, RouterProvider, Routes } from "react-router-dom";
|
|
|
|
import { server } from "../test/server";
|
|
import { renderApp } from "../test/render";
|
|
import { amphora } from "../test/fixtures";
|
|
import { SearchPage } from "./search-page";
|
|
import { SelectSearchPrompt } from "./select-search-prompt";
|
|
import { ObjectDetail } from "../objects/object-detail";
|
|
import "../i18n";
|
|
|
|
function tree() {
|
|
return (
|
|
<Routes>
|
|
<Route path="/search" element={<SearchPage />}>
|
|
<Route index element={<SelectSearchPrompt />} />
|
|
<Route path=":id" element={<ObjectDetail />} />
|
|
</Route>
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
// The search rows are <NavLink>s. Under the shared `renderApp` harness the test
|
|
// subtree lives in a descendant <Routes> under a catch-all `*` data route, where
|
|
// the data router does not intercept the link click (it falls through to a real
|
|
// browser navigation that jsdom rejects). Mounting the search routes as real
|
|
// data-router routes lets RouterProvider intercept the NavLink, which is the
|
|
// data-router equivalent of the old MemoryRouter behavior.
|
|
function renderSearchRouter(route = "/search") {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const router = createMemoryRouter(
|
|
[
|
|
{
|
|
path: "/search",
|
|
element: <SearchPage />,
|
|
children: [
|
|
{ index: true, element: <SelectSearchPrompt /> },
|
|
{ path: ":id", element: <ObjectDetail /> },
|
|
],
|
|
},
|
|
],
|
|
{ initialEntries: [route] },
|
|
);
|
|
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
test("typing searches and renders highlighted rich rows", async () => {
|
|
renderApp(tree(), { route: "/search" });
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
|
|
|
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
|
|
const mark = await screen.findByText("bronze");
|
|
expect(mark.tagName).toBe("MARK");
|
|
// The estimated count lives in a status region so updates are announced.
|
|
expect(screen.getByRole("status")).toHaveTextContent(/~\s*25 results/i);
|
|
expect(screen.getByText(/1962-04-03/)).toBeInTheDocument();
|
|
});
|
|
|
|
test("Load more appends the next page", async () => {
|
|
renderApp(tree(), { route: "/search" });
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
|
await screen.findByText("Bronze figurine");
|
|
|
|
expect(screen.queryByText("Object 21")).toBeNull();
|
|
await userEvent.click(screen.getByRole("button", { name: /load more/i }));
|
|
expect(await screen.findByText("Object 21")).toBeInTheDocument();
|
|
});
|
|
|
|
test("the visibility filter adds the param to the request", async () => {
|
|
let lastVisibility: string | null = "unset";
|
|
server.use(
|
|
http.get("/api/admin/search", ({ request }) => {
|
|
lastVisibility = new URL(request.url).searchParams.get("visibility");
|
|
return HttpResponse.json({ hits: [], estimated_total: 0 });
|
|
}),
|
|
);
|
|
renderApp(tree(), { route: "/search" });
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
|
await userEvent.click(screen.getByRole("button", { name: /^draft$/i }));
|
|
|
|
await waitFor(() => expect(lastVisibility).toBe("draft"));
|
|
});
|
|
|
|
test("empty query shows the prompt; zero results shows empty", async () => {
|
|
renderApp(tree(), { route: "/search" });
|
|
expect(screen.getByText(/type to search/i)).toBeInTheDocument();
|
|
|
|
server.use(
|
|
http.get("/api/admin/search", () => HttpResponse.json({ hits: [], estimated_total: 0 })),
|
|
);
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "zzz");
|
|
expect(await screen.findByText(/no results/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("clicking a result shows the object in the detail pane", async () => {
|
|
renderSearchRouter();
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
|
await userEvent.click(await screen.findByText("Bronze figurine"));
|
|
|
|
expect(await screen.findByText(amphora.object_name)).toBeInTheDocument();
|
|
});
|
|
|
|
test("a 503 shows the search-unavailable message", async () => {
|
|
server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 503 })));
|
|
renderApp(tree(), { route: "/search" });
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
|
expect(await screen.findByText(/not available on this server/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("a 500 shows the generic search error", async () => {
|
|
server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 500 })));
|
|
renderApp(tree(), { route: "/search" });
|
|
await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze");
|
|
expect(await screen.findByText(/^search is unavailable$/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("hydrates query and visibility from the initial URL", async () => {
|
|
renderApp(tree(), { route: "/search?q=bronze" });
|
|
|
|
expect(screen.getByLabelText(/search the collection/i)).toHaveValue("bronze");
|
|
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
|
|
|
|
const { container } = renderApp(tree(), { route: "/search?q=bronze&visibility=internal" });
|
|
|
|
expect(
|
|
within(container).getByRole("button", { name: /^internal$/i }),
|
|
).toHaveAttribute("aria-pressed", "true");
|
|
});
|