mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-04 04:33:45 +00:00
483 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|