Files
beacon-web/tests/features/packets/PacketList.test.tsx
T

422 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, within, act } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PacketList } from "../../../src/features/packets/PacketList";
import i18n from "../../../src/i18n";
import type { WsManager } from "../../../src/api/ws-manager";
import type { PacketSummary, PacketDetail } from "../../../src/types/api";
import type { WsPacketObservation } from "../../../src/types/ws";
const basePackets = () => ({
allPackets: [] as PacketSummary[],
observerOptions: [],
newPacketCount: 0,
acknowledgeNewPackets: () => {},
fetchNextPage: () => {},
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isError: false,
observersByHash: new Map(),
handlePacketObservation: () => {},
handleLagged: () => {},
laggedCount: 0,
dismissLagged: () => {},
});
const usePackets = vi.fn(basePackets);
vi.mock("../../../src/features/packets/usePackets", () => ({
usePackets: (...args: unknown[]) => usePackets(...(args as [])),
}));
const baseDetail = () => ({ data: undefined as PacketDetail | undefined, isLoading: false, isError: false, error: null as Error | null, refetch: vi.fn() });
const usePacketDetail = vi.fn(baseDetail);
vi.mock("../../../src/features/packets/usePacketDetail", () => ({
usePacketDetail: (hash: string | null) => usePacketDetail(hash as never),
}));
vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] }));
vi.mock("../../../src/hooks/useRegion", () => ({
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
}));
// capture the packet handler so tests can push a live observation through it
let packetHandler: ((data: WsPacketObservation["data"]) => void) | null = null;
vi.mock("../../../src/hooks/useWsHandlers", () => ({
useWsPacketHandler: (_manager: unknown, handler: (data: WsPacketObservation["data"]) => void) => {
packetHandler = handler;
},
useWsLaggedHandler: () => {},
}));
// the virtual list needs ResizeObserver in jsdom; stub it down to the wiring under test
vi.mock("../../../src/features/packets/PacketVirtualList", () => ({
PacketVirtualList: ({
packets,
expandedHash,
onToggleExpand,
onOpenAnalyzer,
onViewPath,
}: {
packets: PacketSummary[];
expandedHash: string | null;
onToggleExpand: (hash: string) => void;
onOpenAnalyzer: () => void;
onViewPath: () => void;
}) => (
<div>
<div data-testid="expanded">{String(expandedHash)}</div>
<button type="button" onClick={() => onOpenAnalyzer()}>Open analyzer</button>
<button type="button" onClick={onViewPath}>Map all paths</button>
{packets.map((p) => (
<button
key={p.packetHash}
type="button"
aria-expanded={expandedHash === p.packetHash}
onClick={() => onToggleExpand(p.packetHash)}
>
{p.packetHash}
</button>
))}
</div>
),
}));
const packet = (hash: string): PacketSummary => ({
packetHash: hash, payloadType: 1, payloadTypeName: "ADVERT",
routeType: 1, routeTypeName: "FLOOD",
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1,
});
const observation = (hash: string): WsPacketObservation["data"] => ({
packetHash: hash,
packet: {
payloadType: 1, payloadTypeName: "ADVERT",
routeType: 1, routeTypeName: "FLOOD",
isFirstObservation: false, observationCount: 2,
},
observation: {
observerId: "o1", observerName: "Observer 1", iata: "YOW",
heardAt: 1700000001, rssi: -90, snr: 5, sourceBroker: "b1",
},
});
function renderList(url = "/", props: Partial<Parameters<typeof PacketList>[0]> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
const onAnalyze = props.onAnalyze ?? vi.fn();
const onViewPath = props.onViewPath ?? vi.fn();
const onSelectObservation = props.onSelectObservation ?? vi.fn();
render(
<MemoryRouter initialEntries={[url]}>
<QueryClientProvider client={queryClient}>
<PacketList
wsManager={{} as unknown as WsManager}
onAnalyze={onAnalyze}
onViewPath={onViewPath}
selectedObservationId={null}
onSelectObservation={onSelectObservation}
/>
</QueryClientProvider>
</MemoryRouter>,
);
return { onAnalyze, onViewPath, onSelectObservation, invalidate };
}
describe("PacketList server filter wiring", () => {
it("passes a single selected type to usePackets as the server filter", () => {
usePackets.mockClear();
renderList("/?types=4");
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] });
});
it("passes a multi-select filter server-side so history stays filtered", () => {
usePackets.mockClear();
renderList("/?types=2,4");
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [2, 4] });
});
});
describe("PacketList loading feedback", () => {
afterEach(() => {
usePackets.mockImplementation(basePackets);
});
it("applies latest-path search to the list and explains its scope", () => {
const matching = { ...packet("matched"), payloadType: 4, latestObserver: {
id: "o1", iata: "YOW", pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, pathBytes: "7fa4",
} };
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [matching, packet("other")] }));
renderList("/?sf=path&q=7f%20a4&types=4");
expect(screen.getByText("matched")).toBeInTheDocument();
expect(screen.queryByText("other")).not.toBeInTheDocument();
expect(screen.getByText(/latest relay path in loaded packets/)).toBeInTheDocument();
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] });
});
it("explains an invalid path query instead of silently ignoring it", () => {
renderList("/?sf=path&q=not-hex");
expect(screen.getByRole("alert")).toHaveTextContent("whole hop hashes of the same length");
});
it("shows skeletons instead of the list plus a loading pill during an empty initial load", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true }));
renderList();
expect(screen.queryByTestId("expanded")).toBeNull();
expect(screen.getByRole("status").textContent).toContain("Loading packets…");
});
it("keeps the list and shows the pill while paging in more history", () => {
const pkt = { packetHash: "h1", payloadType: 4, routeType: 1, firstHeardAt: 1, lastHeardAt: 1, observationCount: 1 } as PacketSummary;
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [pkt], isFetchingNextPage: true }));
renderList();
expect(screen.getByTestId("expanded")).toBeTruthy();
expect(screen.getByRole("status").textContent).toContain("Loading packets… (1)");
});
it("surfaces a failed history fetch through the pill", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isError: true }));
renderList();
expect(screen.getByRole("status").textContent).toContain("Failed to load packets");
});
it("renders neither pill nor skeletons when idle", () => {
renderList();
expect(screen.getByTestId("expanded")).toBeTruthy();
expect(screen.queryByRole("status")).toBeNull();
});
});
describe("PacketList expanded row", () => {
afterEach(() => {
usePackets.mockImplementation(basePackets);
usePacketDetail.mockReturnValue(baseDetail());
});
it("expands a row from ?hash without opening the analyzer", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
const { onAnalyze } = renderList("/?tab=Packets&hash=aa11");
expect(screen.getByRole("button", { name: /aa11/ })).toHaveAttribute("aria-expanded", "true");
expect(onAnalyze).not.toHaveBeenCalled();
});
// packet hashes are lowercase hex; a hand-typed or uppercased link must still match the row
it("expands the row from an uppercase ?hash", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
renderList("/?tab=Packets&hash=AA11");
expect(screen.getByRole("button", { name: /aa11/ })).toHaveAttribute("aria-expanded", "true");
expect(usePacketDetail).toHaveBeenLastCalledWith("aa11");
});
it("clicking a row sets ?hash and does not open the analyzer", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
const { onAnalyze } = renderList("/?tab=Packets");
fireEvent.click(screen.getByRole("button", { name: /aa11/ }));
expect(onAnalyze).not.toHaveBeenCalled();
});
it("routes the expansion's Open analyzer through onAnalyze with the expanded hash", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
const { onAnalyze } = renderList("/?tab=Packets&hash=aa11");
fireEvent.click(screen.getByRole("button", { name: "Open analyzer" }));
expect(onAnalyze).toHaveBeenCalledWith("aa11");
});
it("hands the loaded detail to onViewPath", () => {
const detail = { packetHash: "aa11", observations: [] } as unknown as PacketDetail;
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
usePacketDetail.mockReturnValue({ ...baseDetail(), data: detail });
const { onViewPath } = renderList("/?tab=Packets&hash=aa11");
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
expect(onViewPath).toHaveBeenCalledWith(detail);
});
it("does not call onViewPath before the detail has loaded", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
const { onViewPath } = renderList("/?tab=Packets&hash=aa11");
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
expect(onViewPath).not.toHaveBeenCalled();
});
});
describe("PacketList live observation invalidation", () => {
afterEach(() => {
usePackets.mockImplementation(basePackets);
packetHandler = null;
});
it("refetches the expanded row's detail when an observation arrives for it", () => {
const handlePacketObservation = vi.fn();
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")], handlePacketObservation }));
const { invalidate } = renderList("/?tab=Packets&hash=aa11");
invalidate.mockClear();
packetHandler!(observation("aa11"));
expect(handlePacketObservation).toHaveBeenCalledTimes(1);
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["packet-detail", "aa11"] });
});
it("leaves the detail query alone for observations on other packets", () => {
const handlePacketObservation = vi.fn();
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")], handlePacketObservation }));
const { invalidate } = renderList("/?tab=Packets&hash=aa11");
invalidate.mockClear();
packetHandler!(observation("bb22"));
expect(handlePacketObservation).toHaveBeenCalledTimes(1);
expect(invalidate).not.toHaveBeenCalled();
});
it("does not invalidate when no row is expanded", () => {
const handlePacketObservation = vi.fn();
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")], handlePacketObservation }));
const { invalidate } = renderList("/?tab=Packets");
invalidate.mockClear();
packetHandler!(observation("aa11"));
expect(invalidate).not.toHaveBeenCalled();
});
});
describe("PacketList selected packet outside the loaded results", () => {
afterEach(() => {
usePackets.mockImplementation(basePackets);
usePacketDetail.mockReturnValue(baseDetail());
});
it("offers the existing analyzer without scanning history for an unloaded packet", () => {
const fetchNextPage = vi.fn();
usePackets.mockImplementation(() => ({ ...basePackets(), hasNextPage: true, fetchNextPage }));
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
const { onAnalyze } = renderList("/?hash=aa11");
const notice = screen.getByRole("region", { name: "Selected packet" });
expect(notice).toHaveTextContent("outside the loaded results");
fireEvent.click(within(notice).getByRole("button", { name: "Open analyzer" }));
expect(onAnalyze).toHaveBeenCalledWith("aa11");
expect(fetchNextPage).not.toHaveBeenCalled();
});
it("offers the action when current filters hide a loaded selection", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
renderList("/?hash=aa11&q=other");
expect(screen.getByRole("region", { name: "Selected packet" })).toHaveTextContent("outside the loaded results");
fireEvent.click(screen.getByRole("button", { name: "Clear", exact: true }));
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
expect(screen.getByRole("button", { name: "aa11", exact: true })).toHaveAttribute("aria-expanded", "true");
});
it.each(["/?hash=aa11", "/?hash=aa11&analyze=1", "/"])("does not duplicate a loaded row or an analyzer (%s)", (url) => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
renderList(url);
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
});
it("waits for the initial list before declaring the selection outside it", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true }));
renderList("/?hash=aa11");
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
});
it("shows the selected detail loading state", () => {
usePacketDetail.mockReturnValue({ ...baseDetail(), isLoading: true });
renderList("/?hash=aa11");
expect(screen.getByRole("region", { name: "Selected packet" })).toHaveTextContent("Loading selected packet");
});
it.each([[400, "Invalid packet hash"], [404, "Packet not found"], [503, "Failed to load selected packet"]])("explains HTTP %i and lets the user retry", (status, message) => {
const refetch = vi.fn();
usePacketDetail.mockReturnValue({ ...baseDetail(), isError: true, error: Object.assign(new Error("request failed"), { status }), refetch });
renderList("/?hash=aa11");
expect(screen.getByRole("alert")).toHaveTextContent(message);
fireEvent.click(screen.getByRole("button", { name: "Retry selected packet" }));
expect(refetch).toHaveBeenCalledTimes(1);
});
it("dismisses only the selection, leaving the current filters in place", () => {
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
renderList("/?hash=aa11&q=other&types=4");
fireEvent.click(screen.getByRole("button", { name: "Dismiss selected packet" }));
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
expect(screen.getByTestId("expanded")).toHaveTextContent("null");
expect(screen.getByPlaceholderText("Search by hash...")).toHaveValue("other");
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] });
});
});
describe("PacketList hash search", () => {
afterEach(() => {
vi.useRealTimers();
usePackets.mockImplementation(basePackets);
});
it("sends a typed hex hash search server-side once the search box settles", () => {
vi.useFakeTimers();
renderList();
fireEvent.change(screen.getByPlaceholderText("Search by hash..."), { target: { value: "6DDF" } });
expect(usePackets).toHaveBeenLastCalledWith(false, null);
act(() => { vi.runOnlyPendingTimers(); });
expect(usePackets).toHaveBeenLastCalledWith(false, { hashPrefix: "6ddf" });
});
it("starts with the URL's hash search already server-side", () => {
renderList("/?q=6ddf62");
expect(usePackets).toHaveBeenLastCalledWith(false, { hashPrefix: "6ddf62" });
});
it("still filters live packets client-side while a hash search is active", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("6ddf0001"), packet("aa110002")] }));
renderList("/?q=6ddf");
expect(screen.getByRole("button", { name: "6ddf0001" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "aa110002" })).toBeNull();
});
});
describe("PacketList in French", () => {
it("translates the banners and the selected-packet card", async () => {
await i18n.changeLanguage("fr");
usePackets.mockImplementation(() => ({ ...basePackets(), laggedCount: 2 }));
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
renderList("/?hash=aa11");
expect(screen.getByText("2 paquets perdus — les données peuvent être incomplètes")).toBeInTheDocument();
expect(screen.getByText("Paquets en direct")).toBeInTheDocument();
const card = screen.getByRole("region", { name: "Paquet sélectionné" });
expect(card).toHaveTextContent("Le paquet sélectionné est hors des résultats chargés.");
expect(within(card).getByRole("button", { name: "Ouvrir l’analyseur" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Fermer le paquet sélectionné" })).toBeInTheDocument();
});
it("translates the error state and the path-search hint", async () => {
await i18n.changeLanguage("fr");
usePackets.mockImplementation(basePackets);
usePacketDetail.mockReturnValue({ ...baseDetail(), isError: true, error: Object.assign(new Error("x"), { status: 404 }) });
renderList("/?hash=aa11&sf=path");
expect(screen.getByText("Paquet introuvable.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Réessayer de charger le paquet sélectionné" })).toHaveTextContent("Réessayer");
expect(screen.getByText(/Recherche dans le dernier trajet de relais/)).toBeInTheDocument();
});
});