Files

483 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { usePackets } from "../../../src/features/packets/usePackets";
import type { PacketServerFilter } from "../../../src/features/packets/types";
import type { PacketSummary } from "../../../src/types/api";
import type { WsPacketObservation } from "../../../src/types/ws";
import { noteRateLimited, noteRequestOk } from "../../../src/api/rate-limit";
vi.mock("../../../src/hooks/useRegion", () => ({
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
}));
const getPackets = vi.fn();
vi.mock("../../../src/api/client", () => ({
getPackets: (...args: unknown[]) => getPackets(...args),
}));
function packet(hash: string): PacketSummary {
return {
packetHash: hash,
payloadType: 4,
payloadTypeName: "ADVERT",
routeType: 1,
routeTypeName: "FLOOD",
firstHeardAt: 1,
lastHeardAt: 1,
observationCount: 1,
} as PacketSummary;
}
function seedThreePages(qc: QueryClient) {
qc.setQueryData(["packets", "YOW"], {
pages: [
{ items: [packet("a1")], nextCursor: 200 },
{ items: [packet("b2")], nextCursor: 100 },
{ items: [packet("c3")], nextCursor: null },
],
pageParams: [undefined, 200, 100],
});
}
describe("usePackets gap healing", () => {
let qc: QueryClient;
beforeEach(() => {
getPackets.mockReset();
// nextCursor stays truthy so an invalidate would replay every cached page — the behavior under test
getPackets.mockResolvedValue({ items: [packet("fresh")], nextCursor: 999 });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
it("refetches only the first page on mount instead of trusting stale cached pages", async () => {
seedThreePages(qc);
renderHook(() => usePackets(), { wrapper });
// the remount gap is healed with a single first-page fetch, not a 3-page replay
await waitFor(() => expect(getPackets).toHaveBeenCalled());
await waitFor(() => {
const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"]);
expect(data?.pages).toHaveLength(1);
});
expect(getPackets).toHaveBeenCalledTimes(1);
expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined });
});
it("resets to a single first-page fetch on a lagged notice (no page-by-page storm)", async () => {
const { result } = renderHook(() => usePackets(), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
// simulate deep scroll state, then a lag notice
seedThreePages(qc);
getPackets.mockClear();
result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 });
await waitFor(() => expect(getPackets).toHaveBeenCalled());
await waitFor(() => {
const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"]);
expect(data?.pages).toHaveLength(1);
});
expect(getPackets).toHaveBeenCalledTimes(1);
expect(result.current.laggedCount).toBe(5);
});
it("skips the lag reset while the API is rate-limiting us, but still counts the drop", async () => {
const { result } = renderHook(() => usePackets(), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
seedThreePages(qc);
getPackets.mockClear();
noteRateLimited(10_000);
act(() => {
result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 });
});
noteRequestOk();
expect(getPackets).not.toHaveBeenCalled();
expect(qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"])?.pages).toHaveLength(3);
expect(result.current.laggedCount).toBe(5);
});
});
describe("usePackets server filter", () => {
let qc: QueryClient;
beforeEach(() => {
getPackets.mockReset();
getPackets.mockResolvedValue({ items: [packet("fresh")], nextCursor: 999 });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
vi.unstubAllGlobals();
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
it("fetches filtered history under its own query key, leaving the unfiltered entry alone", async () => {
renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
expect(getPackets.mock.calls[0]![0]).toEqual(["YOW"]);
expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined, payloadType: 4 });
await waitFor(() => {
expect(qc.getQueryData(["packets", "YOW", { payloadType: 4 }])).toBeDefined();
});
expect(qc.getQueryData(["packets", "YOW"])).toBeUndefined();
});
it("carries the filter on subsequent pages", async () => {
const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.fetchNextPage();
});
expect(getPackets).toHaveBeenCalledTimes(2);
expect(getPackets.mock.calls[1]![1]).toEqual({ cursor: 999, payloadType: 4 });
});
it("reuses the cached unfiltered pages when the filter clears (no refetch)", async () => {
const { result, rerender } = renderHook(
({ filter }: { filter: PacketServerFilter | null }) => usePackets(false, filter),
{ initialProps: { filter: null as PacketServerFilter | null }, wrapper },
);
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
rerender({ filter: { payloadType: 4 } });
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(2));
rerender({ filter: null });
await waitFor(() => expect(result.current.allPackets.length).toBeGreaterThan(0));
expect(getPackets).toHaveBeenCalledTimes(2); // staleTime Infinity + unchanged unfiltered key
});
it("keeps the live buffer when the filter changes", async () => {
const rafCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
const { result, rerender } = renderHook(
({ filter }: { filter: PacketServerFilter | null }) => usePackets(false, filter),
{ initialProps: { filter: null as PacketServerFilter | null }, wrapper },
);
await waitFor(() => expect(getPackets).toHaveBeenCalled());
act(() => {
result.current.handlePacketObservation(observation("p1"));
rafCallbacks.splice(0).forEach((cb) => cb(0));
});
expect(result.current.newPacketCount).toBe(1);
rerender({ filter: { payloadType: 4 } });
expect(result.current.allPackets.map((p) => p.packetHash)).toContain("p1");
expect(result.current.newPacketCount).toBe(1); // store untouched by the key switch
});
it("reports isLoading during the first fetch of a key and clears it after", async () => {
let resolveFetch!: (v: unknown) => void;
getPackets.mockReturnValueOnce(new Promise((r) => (resolveFetch = r)));
const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(true));
await act(async () => {
resolveFetch({ items: [packet("fresh")], nextCursor: null });
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
});
it("reports isError when the history fetch fails", async () => {
getPackets.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => usePackets(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
});
it("lag reset collapses the filtered entry and refetches with the filter", async () => {
const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
qc.setQueryData(["packets", "YOW", { payloadType: 4 }], {
pages: [
{ items: [packet("a1")], nextCursor: 200 },
{ items: [packet("b2")], nextCursor: 100 },
{ items: [packet("c3")], nextCursor: null },
],
pageParams: [undefined, 200, 100],
});
getPackets.mockClear();
result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 });
await waitFor(() => expect(getPackets).toHaveBeenCalled());
await waitFor(() => {
const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW", { payloadType: 4 }]);
expect(data?.pages).toHaveLength(1);
});
expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined, payloadType: 4 });
});
});
describe("usePackets path and endpoint fields", () => {
let qc: QueryClient;
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
getPackets.mockReset();
getPackets.mockResolvedValue({ items: [], nextCursor: null });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rafCallbacks = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
it("carries path and endpoint fields from the WS observation into latestObserver", () => {
const { result } = renderHook(() => usePackets(false, undefined), { wrapper });
act(() => {
result.current.handlePacketObservation({
packetHash: "AA11",
packet: {
payloadType: 1,
payloadTypeName: "ADVERT",
routeType: 1,
routeTypeName: "FLOOD",
isFirstObservation: true,
observationCount: 1,
},
observation: {
observerId: "obs-1",
observerName: "Raven",
iata: "YVR",
heardAt: 1700000000,
rssi: -94,
snr: -7.5,
sourceBroker: "b1",
pathLength: { raw: "42", hashSize: 1, hopCount: 2 },
pathBytes: "7fa4",
resolvedSource: { confidence: "high", nodes: [{ id: "n1", publicKey: "ab", name: "Salish" }] },
resolvedDestination: null,
},
});
rafCallbacks.splice(0).forEach((cb) => cb(0));
});
const obs = result.current.allPackets[0]!.latestObserver;
expect(obs?.pathLength).toEqual({ raw: "42", hashSize: 1, hopCount: 2 });
expect(obs?.pathBytes).toBe("7fa4");
expect(obs?.resolvedSource?.nodes[0]!.name).toBe("Salish");
expect(obs?.resolvedDestination).toBeUndefined();
});
});
function observation(
hash: string,
over: { heardAt?: number; observerId?: string; observationCount?: number } = {},
): WsPacketObservation["data"] {
return {
packetHash: hash,
packet: {
payloadType: 4,
payloadTypeName: "ADVERT",
routeType: 1,
routeTypeName: "FLOOD",
isFirstObservation: true,
observationCount: over.observationCount ?? 1,
},
observation: {
observerId: over.observerId ?? "o1",
observerName: "Obs",
iata: "YOW",
heardAt: over.heardAt ?? 1,
rssi: -80,
snr: 5,
sourceBroker: "b",
},
};
}
describe("usePackets live heard window", () => {
let qc: QueryClient;
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
getPackets.mockReset();
getPackets.mockResolvedValue({ items: [], nextCursor: null });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rafCallbacks = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const flushRaf = () => rafCallbacks.splice(0).forEach((cb) => cb(0));
// Each WS message carries only its own heardAt, so a second observation of the same packet used to
// collapse the window to a single instant — the expanded row then read "spread 0.000s".
it("widens the heard window across observations instead of collapsing it", async () => {
const { result } = renderHook(() => usePackets(), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalled());
act(() => {
result.current.handlePacketObservation(observation("p1", { heardAt: 2000 }));
flushRaf();
});
act(() => {
result.current.handlePacketObservation(observation("p1", { heardAt: 5000 }));
flushRaf();
});
act(() => {
result.current.handlePacketObservation(observation("p1", { heardAt: 1000 }));
flushRaf();
});
const packet = result.current.allPackets.find((p) => p.packetHash === "p1")!;
expect(packet.firstHeardAt).toBe(1000);
expect(packet.lastHeardAt).toBe(5000);
});
it("still replaces every other field with the newest observation", async () => {
const { result } = renderHook(() => usePackets(), { wrapper });
await waitFor(() => expect(getPackets).toHaveBeenCalled());
act(() => {
result.current.handlePacketObservation(observation("p1", { heardAt: 2000, observerId: "o1", observationCount: 1 }));
flushRaf();
});
act(() => {
result.current.handlePacketObservation(observation("p1", { heardAt: 3000, observerId: "o2", observationCount: 2 }));
flushRaf();
});
const packet = result.current.allPackets.find((p) => p.packetHash === "p1")!;
expect(packet.latestObserver?.id).toBe("o2");
expect(packet.observationCount).toBe(2);
});
});
describe("usePackets freeze while scrolled away", () => {
let qc: QueryClient;
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
getPackets.mockReset();
getPackets.mockResolvedValue({ items: [], nextCursor: null });
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
rafCallbacks = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const flushRaf = () => rafCallbacks.splice(0).forEach((cb) => cb(0));
async function mount() {
const view = renderHook(({ frozen }) => usePackets(frozen), {
initialProps: { frozen: false },
wrapper,
});
await waitFor(() => expect(getPackets).toHaveBeenCalled());
return view;
}
it("withholds live packets prepended while frozen, but still counts them", async () => {
const { result, rerender } = await mount();
act(() => {
result.current.handlePacketObservation(observation("p1"));
flushRaf();
});
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p1"]);
rerender({ frozen: true });
act(() => {
result.current.handlePacketObservation(observation("p2"));
flushRaf();
});
// frozen: the rendered list stays on p1; the banner still counts the held packet
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p1"]);
expect(result.current.newPacketCount).toBe(2);
});
it("reveals held packets once unfrozen", async () => {
const { result, rerender } = await mount();
act(() => {
result.current.handlePacketObservation(observation("p1"));
flushRaf();
});
rerender({ frozen: true });
act(() => {
result.current.handlePacketObservation(observation("p2"));
flushRaf();
});
rerender({ frozen: false });
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["p2", "p1"]);
});
it("clears the new-packet count on acknowledge", async () => {
const { result } = await mount();
act(() => {
result.current.handlePacketObservation(observation("p1"));
result.current.handlePacketObservation(observation("p2"));
flushRaf();
});
expect(result.current.newPacketCount).toBe(2);
act(() => result.current.acknowledgeNewPackets());
expect(result.current.newPacketCount).toBe(0);
});
});