Files
beacon-web/tests/components/AppShell.test.tsx
T
MrAlders0n 4f0ad44d25 Filter the region picker by IATA or airport name
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.
2026-07-27 23:07:24 -04:00

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();
});
});