Files
beacon-web/tests/features/stats/useLiveStats.test.tsx
T
MrAlders0n 3e43cfd009 fix: follow up on last week's merges (#55-#101) (#102)
Bugs: clear compare params on tab change, recover channel live messages after a 429,
observer copy link keeps only region params, route table no longer navigates on region
change with nothing selected, map borders memo on data identity, live overview can't
resurrect a failed query, French relative times, no language write on first visit,
0/0 node locations hidden outside the map, path/signal stats use the shared retry rule.

i18n: node/packet/mesh/observer strings that were still hardcoded after #62.

Cleanup: formatUtc, ACTION_BUTTON_CLASS, observer-stats, useWindowedStats, observer-id;
observer pages use StatCard/Card/Field/Segmented/SearchBar/SelectDropdown; one details
chrome on the stats tabs; comment and translations-guide trims; localStorage stub for
Node 26 in the test setup.
2026-09-30 10:19:32 -04:00

113 lines
4.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useLiveOverview, useLiveObserver } from "../../../src/features/stats/useLiveStats";
import type { StatsOverview } from "../../../src/features/stats/types";
import type { WsManager } from "../../../src/api/ws-manager";
import type { WsObserverStatus, WsPacketObservation } from "../../../src/types/ws";
vi.mock("../../../src/hooks/useRegion", () => ({
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
}));
const overview: StatsOverview = {
totalPackets: 100,
totalObservations: 200,
activeObservers: 5,
activeIatas: 2,
windowHours: 24,
};
// Captures the packet handler the hook registers and lets the test push events through it.
function fakeManager() {
const handlers: Array<(d: WsPacketObservation["data"]) => void> = [];
const manager = {
onPacketObservation: (h: (d: WsPacketObservation["data"]) => void) => {
handlers.push(h);
return () => {};
},
} as unknown as WsManager;
return { manager, emit: (d: WsPacketObservation["data"]) => handlers.forEach((h) => h(d)) };
}
describe("useLiveOverview", () => {
let rafCallbacks: FrameRequestCallback[];
beforeEach(() => {
rafCallbacks = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("bumps the cached stats-overview counters on packet observations", () => {
const qc = new QueryClient();
qc.setQueryData<StatsOverview>(["stats-overview", "YOW"], overview);
const { manager, emit } = fakeManager();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
renderHook(() => useLiveOverview(manager), { wrapper });
emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]);
emit({ packet: { isFirstObservation: false } } as WsPacketObservation["data"]);
rafCallbacks.forEach((cb) => cb(0)); // flush the coalesced frame
const after = qc.getQueryData<StatsOverview>(["stats-overview", "YOW"]);
expect(after?.totalPackets).toBe(101);
expect(after?.totalObservations).toBe(202);
});
it("drops pending deltas instead of resurrecting a failed overview query", () => {
const qc = new QueryClient();
qc.setQueryData<StatsOverview>(["stats-overview", "YOW"], overview);
qc.getQueryCache().find({ queryKey: ["stats-overview", "YOW"] })?.setState({ status: "error", error: new Error("refetch failed") });
const { manager, emit } = fakeManager();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
renderHook(() => useLiveOverview(manager), { wrapper });
emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]);
rafCallbacks.forEach((cb) => cb(0));
const state = qc.getQueryState<StatsOverview>(["stats-overview", "YOW"]);
expect(state?.status).toBe("error");
expect(state?.data).toEqual(overview);
});
});
describe("useLiveObserver", () => {
it("refreshes the selected observer's telemetry, but not its heard activity, on a status event", () => {
const qc = new QueryClient();
qc.setQueryData(["observer-telemetry", "obs-1", "24h", "1h"], { range: "24h", interval: "1h", points: [] });
qc.setQueryData(["observer-activity", "obs-1", "24h"], { range: "24h", interval: "15m", radio: null, payloadTypes: [], points: [] });
const handlers: Array<(d: WsObserverStatus["data"]) => void> = [];
const manager = {
onObserverStatus: (h: (d: WsObserverStatus["data"]) => void) => {
handlers.push(h);
return () => {};
},
} as unknown as WsManager;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
renderHook(() => useLiveObserver(manager, "obs-1", "24h"), { wrapper });
handlers.forEach((h) => h({ observerId: "obs-1" } as WsObserverStatus["data"]));
expect(qc.getQueryState(["observer-telemetry", "obs-1", "24h", "1h"])?.isInvalidated).toBe(true);
// heard activity changes with packets, not status, and refreshes on its own interval
expect(qc.getQueryState(["observer-activity", "obs-1", "24h"])?.isInvalidated).toBe(false);
});
});