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 { matchesFilters } from "../../../src/features/packets/usePacketFilters"; import { EMPTY_FILTERS } from "../../../src/features/packets/types"; 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"; const region: { iatas: string[] | undefined; regionKey: string; isResolved: boolean } = { iatas: ["YOW"], regionKey: "YOW", isResolved: true }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region, })); 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 }) => ( {children} ); 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 }) => ( {children} ); 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 }) => ( {children} ); it("carries path and endpoint fields from the WS observation into latestObserver", async () => { const history: PacketSummary = { ...packet("history"), latestObserver: { id: "obs-rest", iata: "YVR", pathLength: { raw: "42", hashSize: 1, hopCount: 2 }, pathBytes: "7fa4", } }; getPackets.mockResolvedValue({ items: [history], nextCursor: null }); const { result } = renderHook(() => usePackets(false, undefined), { wrapper }); await waitFor(() => expect(result.current.allPackets).toHaveLength(1)); 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: "YOW", 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(); const filters = { ...EMPTY_FILTERS, searchField: "path" as const, search: "7F A4" }; expect(result.current.allPackets.filter(p => matchesFilters(p, filters)).map(p => p.packetHash)).toEqual(["AA11", "history"]); expect(result.current.allPackets.filter(p => matchesFilters(p, { ...filters, search: "ff" }))).toEqual([]); }); }); 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 }) => ( {children} ); const flushRaf = () => rafCallbacks.splice(0).forEach((cb) => cb(0)); it("keeps summaries from history and passes summaries through live observations", async () => { getPackets.mockResolvedValue({ items: [{ ...packet("history"), summary: "Historical advert" }], nextCursor: null }); const { result } = renderHook(() => usePackets(), { wrapper }); await waitFor(() => expect(result.current.allPackets.find((p) => p.packetHash === "history")?.summary).toBe("Historical advert")); const event = observation("live"); event.packet.summary = "Live advert 📡"; act(() => { result.current.handlePacketObservation(event); flushRaf(); }); expect(result.current.allPackets.find((p) => p.packetHash === "live")?.summary).toBe("Live advert 📡"); expect(result.current.allPackets.find((p) => p.packetHash === "history")?.summary).toBe("Historical advert"); }); // 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); }); // the old subscription keeps delivering until the server takes the new one, after the buffer reset it("drops observations from an IATA outside the current region", async () => { Object.assign(region, { iatas: ["YVR"], regionKey: "YVR" }); try { const { result } = renderHook(() => usePackets(), { wrapper }); await waitFor(() => expect(getPackets).toHaveBeenCalled()); act(() => { result.current.handlePacketObservation(observation("stale")); const fresh = observation("fresh"); fresh.observation.iata = "YVR"; result.current.handlePacketObservation(fresh); flushRaf(); }); expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["fresh"]); } finally { Object.assign(region, { iatas: ["YOW"], regionKey: "YOW" }); } }); it("keeps every observation when all regions are selected", async () => { Object.assign(region, { iatas: undefined, regionKey: "*" }); try { const { result } = renderHook(() => usePackets(), { wrapper }); await waitFor(() => expect(getPackets).toHaveBeenCalled()); act(() => { result.current.handlePacketObservation(observation("any")); flushRaf(); }); expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["any"]); } finally { Object.assign(region, { iatas: ["YOW"], regionKey: "YOW" }); } }); }); 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 }) => ( {children} ); 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); }); }); describe("usePackets while the region loads", () => { afterEach(() => { Object.assign(region, { iatas: ["YOW"], regionKey: "YOW", isResolved: true }); }); it("fetches nothing until the region resolves, then fetches only its IATAs", async () => { getPackets.mockReset(); getPackets.mockResolvedValue({ items: [], nextCursor: null }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => {children}; Object.assign(region, { iatas: undefined, regionKey: "pending:onqc", isResolved: false }); const { result, rerender } = renderHook(() => usePackets(), { wrapper }); await new Promise((done) => setTimeout(done, 50)); expect(getPackets).not.toHaveBeenCalled(); expect(result.current.isLoading).toBe(true); Object.assign(region, { iatas: ["YOW"], regionKey: "YOW", isResolved: true }); rerender(); await waitFor(() => expect(getPackets).toHaveBeenCalledWith(["YOW"], expect.anything())); }); }); describe("usePackets deep paging", () => { it("keeps the newest history page after many older pages load", async () => { getPackets.mockReset(); getPackets.mockImplementation((_iatas: unknown, opts: { cursor?: number }) => { const n = opts.cursor ?? 0; return Promise.resolve({ items: [packet(`p${n}`)], nextCursor: n + 1 }); }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const { result } = renderHook(() => usePackets(), { wrapper }); await waitFor(() => expect(result.current.allPackets).toHaveLength(1)); for (let i = 1; i <= 25; i++) { await act(() => result.current.fetchNextPage()); } await waitFor(() => expect(result.current.allPackets).toHaveLength(26)); expect(result.current.allPackets[0]!.packetHash).toBe("p0"); }); });