mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 17:23:45 +00:00
Typing narrows both groups: airports match on code or display name, regions match on their name or on any member code — a code-only match tags the row with the code that hit, so it doesn't look like a stray result. Empty groups drop their headers so the divider never dangles. The panel body moves into its own component so the query dies with the dropdown, and focus returns to the trigger on close.
222 lines
8.8 KiB
TypeScript
222 lines
8.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { AppShell } from "../../src/components/AppShell";
|
|
import { RegionProvider } from "../../src/hooks/useRegion";
|
|
import { ALL_REGIONS } from "../../src/hooks/region-selection";
|
|
import { getIatas, getRegions, getRegion } from "../../src/api/client";
|
|
import type { WsManager } from "../../src/api/ws-manager";
|
|
import pkg from "../../package.json";
|
|
|
|
vi.mock("../../src/api/client", () => ({
|
|
getIatas: vi.fn(),
|
|
getRegions: vi.fn(),
|
|
getRegion: vi.fn(),
|
|
}));
|
|
|
|
const wsManager = {
|
|
onStatusChange: () => () => {},
|
|
getStatus: () => "connected",
|
|
getLastEventTimestamp: () => Date.now(),
|
|
} as unknown as WsManager;
|
|
|
|
function renderShell() {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={client}>
|
|
<RegionProvider defaultSelection={ALL_REGIONS}>
|
|
<AppShell activeTab="Packets" onTabChange={() => {}} wsManager={wsManager}>
|
|
<div />
|
|
</AppShell>
|
|
</RegionProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(getIatas).mockReset();
|
|
vi.mocked(getRegions).mockReset().mockResolvedValue([]);
|
|
vi.mocked(getRegion).mockReset();
|
|
});
|
|
|
|
describe("AppShell", () => {
|
|
it("footer shows the package.json version", () => {
|
|
vi.mocked(getIatas).mockResolvedValue([]);
|
|
renderShell();
|
|
expect(screen.getByText(`BEACON v${pkg.version}`)).toBeInTheDocument();
|
|
});
|
|
|
|
it("region picker shows an error state when the IATA list fails to load", async () => {
|
|
vi.mocked(getIatas).mockRejectedValue(new Error("boom"));
|
|
renderShell();
|
|
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
|
|
|
await waitFor(() => expect(screen.getByText("Failed to load")).toBeInTheDocument());
|
|
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
const IATAS = [
|
|
{ iata: "YVR", displayName: "Vancouver International" },
|
|
{ iata: "YYJ", displayName: "Victoria International" },
|
|
{ iata: "YYZ", displayName: "Toronto Pearson" },
|
|
{ iata: "XXX" }, // auto-created from packet traffic — no displayName
|
|
];
|
|
|
|
const REGIONS = [
|
|
{ id: 1, slug: "western-canada", name: "Western Canada", iatas: ["YVR", "YYJ"] },
|
|
{ id: 2, slug: "eastern-canada", name: "Eastern Canada", iatas: ["YYZ"] },
|
|
// name and member code both contain "YYJ", so one query exercises both match paths at once
|
|
{ id: 3, slug: "yyj-corridor", name: "YYJ Corridor", iatas: ["YYJ"] },
|
|
];
|
|
|
|
// Opens the picker and returns the filter input, once both region and IATA lists have landed.
|
|
// The trigger is focused first because a real browser click focuses the button; jsdom's does not.
|
|
async function openPicker() {
|
|
const trigger = screen.getByRole("button", { name: /REGION/ });
|
|
trigger.focus();
|
|
fireEvent.click(trigger);
|
|
await waitFor(() => expect(screen.getByText("Western Canada")).toBeInTheDocument());
|
|
return screen.getByPlaceholderText(/Filter/);
|
|
}
|
|
|
|
describe("region picker filter", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(getIatas).mockResolvedValue(IATAS);
|
|
vi.mocked(getRegions).mockResolvedValue(REGIONS.map(({ id, slug, name }) => ({ id, slug, name })));
|
|
vi.mocked(getRegion).mockImplementation(async (id: number) => REGIONS.find((r) => r.id === id)!);
|
|
});
|
|
|
|
it("focuses the filter input when the picker opens", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
expect(input).toHaveFocus();
|
|
});
|
|
|
|
it("hands focus back to the trigger when the panel closes", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
expect(input).toHaveFocus();
|
|
|
|
fireEvent.keyDown(input, { key: "Escape" }); // empty query, so this closes
|
|
expect(screen.getByRole("button", { name: /REGION/ })).toHaveFocus();
|
|
});
|
|
|
|
it("narrows the IATA list by code, ignoring surrounding whitespace", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: " yvr " } });
|
|
|
|
expect(screen.getByText("Vancouver International")).toBeInTheDocument();
|
|
expect(screen.queryByText("Toronto Pearson")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Victoria International")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("No matches")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("narrows the IATA list by display name", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "toronto" } });
|
|
|
|
expect(screen.getByText("Toronto Pearson")).toBeInTheDocument();
|
|
expect(screen.queryByText("Vancouver International")).not.toBeInTheDocument();
|
|
// no region name or member code contains "toronto"
|
|
expect(screen.queryByText("Regions")).not.toBeInTheDocument();
|
|
// nothing above it, so the header must not draw a divider
|
|
expect(screen.getByText("IATA")).not.toHaveClass("border-t");
|
|
});
|
|
|
|
it("surfaces a region whose member IATA matches, tagged with the matching code", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "yvr" } });
|
|
|
|
expect(screen.getByText("Western Canada")).toBeInTheDocument();
|
|
expect(screen.getByText("· YVR")).toBeInTheDocument();
|
|
expect(screen.queryByText("Eastern Canada")).not.toBeInTheDocument();
|
|
// something is above it now, so the header does draw a divider
|
|
expect(screen.getByText("IATA")).toHaveClass("border-t");
|
|
});
|
|
|
|
it("tags a code match but not a name match, for the same query", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
// "yyj" is in YYJ Corridor's *name* and in Western Canada's *member codes*
|
|
fireEvent.change(input, { target: { value: "yyj" } });
|
|
|
|
expect(screen.getByRole("button", { name: /Western Canada/ }).textContent).toContain("· YYJ");
|
|
expect(screen.getByRole("button", { name: /YYJ Corridor/ }).textContent).not.toContain("·");
|
|
});
|
|
|
|
it("omits the matched-code tag when the region matched on its name", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "western" } });
|
|
|
|
expect(screen.getByText("Western Canada")).toBeInTheDocument();
|
|
expect(screen.queryByText("· YVR")).not.toBeInTheDocument();
|
|
// nothing in the IATA group matches, so its header goes too
|
|
expect(screen.queryByText("IATA")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps an IATA with no display name matchable by code", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "xxx" } });
|
|
|
|
// code column plus the displayName fallback, so the row renders the code twice
|
|
expect(screen.getAllByText("XXX")).toHaveLength(2);
|
|
expect(screen.queryByText("YVR")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("filters out the All Regions row unless it matches", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
|
|
fireEvent.change(input, { target: { value: "yvr" } });
|
|
expect(screen.queryByText("All Regions")).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(input, { target: { value: "all" } });
|
|
expect(screen.getByText("All Regions")).toBeInTheDocument();
|
|
|
|
fireEvent.change(input, { target: { value: "" } });
|
|
expect(screen.getByText("All Regions")).toBeInTheDocument();
|
|
});
|
|
|
|
it("reports no matches without leaving a dangling group header", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "zzzz" } });
|
|
|
|
expect(screen.getByText("No matches")).toBeInTheDocument();
|
|
expect(screen.queryByText("Regions")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("IATA")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("All Regions")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("clears the query on Escape before closing the picker", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "yvr" } });
|
|
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
expect(screen.getByPlaceholderText(/Filter/)).toHaveValue("");
|
|
expect(screen.getByText("All Regions")).toBeInTheDocument();
|
|
|
|
fireEvent.keyDown(screen.getByPlaceholderText(/Filter/), { key: "Escape" });
|
|
expect(screen.queryByText("All Regions")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("drops the query when the picker is reopened", async () => {
|
|
renderShell();
|
|
const input = await openPicker();
|
|
fireEvent.change(input, { target: { value: "yvr" } });
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
|
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
|
|
|
await waitFor(() => expect(screen.getByPlaceholderText(/Filter/)).toHaveValue(""));
|
|
expect(screen.getByText("Eastern Canada")).toBeInTheDocument();
|
|
});
|
|
});
|