Files
beacon-web/tests/features/stats/ObserverTab.test.tsx

244 lines
9.1 KiB
TypeScript
Raw Permalink 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, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { ObserverTab } from "../../../src/features/stats/ObserverTab";
import i18n from "../../../src/i18n";
import { ApiError } from "../../../src/api/client";
import type { Observer } from "../../../src/features/observers/types";
import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../../../src/features/stats/types";
import type { WsManager } from "../../../src/api/ws-manager";
vi.mock("../../../src/hooks/useRegion", () => ({
useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }),
}));
vi.mock("../../../src/features/stats/useStats", () => ({
useTopObservers: () => ({ data: [], isLoading: false }),
}));
vi.mock("../../../src/features/stats/useLiveStats", () => ({
useLiveObserver: () => {},
}));
// ECharts needs a real canvas; the tab's behaviour is in which cards it renders, not the pixels
vi.mock("../../../src/features/stats/EChart", () => ({
EChart: ({ option }: { option: unknown }) => <div data-testid="chart" data-option={JSON.stringify(option)} />,
}));
const telemetryResult = { data: undefined as ObserverTelemetry | undefined, isLoading: false, isError: false };
const activityResult = {
data: undefined as ObserverActivity | undefined,
isLoading: false,
isPlaceholderData: false,
isError: false,
error: null as unknown,
dataUpdatedAt: 0,
};
vi.mock("../../../src/features/stats/useTelemetry", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../src/features/stats/useTelemetry")>()),
useObserver: () => ({ data: observer }),
useObserverTelemetry: () => telemetryResult,
useObserverActivity: () => activityResult,
}));
const observer: Observer = {
id: "obs-1",
displayName: "Rooftop",
iata: "YOW",
status: "online",
publicKey: "aa",
firstSeen: 0,
lastSeen: Date.now(),
observationCount: 12,
brokers: [],
radioFreqMhz: 910.525,
radioSf: 7,
radioBwKhz: 62.5,
radioCr: 5,
};
const H = 3_600_000;
const point = (t: number, p: Partial<TelemetryPoint>): TelemetryPoint => ({
t,
batteryMv: null,
airtimeTxSecs: null,
airtimeRxSecs: null,
noiseFloorDb: null,
uptimeSeconds: null,
queueLength: null,
receiveErrors: null,
...p,
});
const telemetry: ObserverTelemetry = {
range: "24h",
interval: "1h",
points: [
point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }),
point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 }),
],
};
const activity: ObserverActivity = {
range: "24h",
interval: "15m",
radio: { freqMhz: 910.525, sf: 7, bwKhz: 62.5, cr: 5, preambleSymbols: 32 },
payloadTypes: [{ payloadType: 4, payloadTypeName: "ADVERT", count: 9 }],
points: [{ t: Date.now() - 900_000, observations: 9, airtimeMs: 2200, snrAvg: 6.1, snrMin: -1, rssiAvg: -95 }],
};
function renderTab() {
const qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return render(
<ObserverTab range="24h" selectedObserverId="obs-1" onSelectObserver={() => {}} wsManager={{} as WsManager} />,
{ wrapper },
);
}
beforeEach(() => {
observer.brokers = [];
telemetryResult.data = telemetry;
telemetryResult.isLoading = false;
telemetryResult.isError = false;
activityResult.data = activity;
activityResult.isLoading = false;
activityResult.isPlaceholderData = false;
activityResult.isError = false;
activityResult.error = null;
activityResult.dataUpdatedAt = Date.now();
});
describe("ObserverTab", () => {
it("shows the latest reported RX and TX airtime as percent beside the airtime chart", () => {
renderTab();
// +54 s RX and +36 s TX over the hour between the two reports
expect(screen.getByText(/RX 1\.5%/)).toBeInTheDocument();
expect(screen.getByText(/TX 1%/)).toBeInTheDocument();
});
it("renders the heard charts when the server has activity for the observer", () => {
renderTab();
expect(screen.getByText(/channel busy/i)).toBeInTheDocument();
expect(screen.getByText(/packets heard per 15 min/i)).toBeInTheDocument();
expect(screen.getByText(/received signal/i)).toBeInTheDocument();
expect(screen.getByText(/packet-type mix/i)).toBeInTheDocument();
});
it("names the radio settings the busy percent assumes, in the same form as the header", () => {
renderTab();
expect(screen.getAllByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toHaveLength(2);
});
it("hides the heard charts entirely when the server does not have the endpoint", () => {
activityResult.data = undefined;
activityResult.isError = true;
activityResult.error = new ApiError(404, "not_found", "no route");
renderTab();
expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument();
expect(screen.queryByText(/heard per/i)).not.toBeInTheDocument();
});
it("leaves the header airtime stat out when the observer reports no telemetry", () => {
const zero = point(0, { airtimeRxSecs: 0, airtimeTxSecs: 0 });
telemetryResult.data = { ...telemetry, points: [zero, { ...zero, t: H }] };
renderTab();
expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument();
});
it("keeps the last successful activity visible after a failed poll", () => {
activityResult.isError = true;
activityResult.error = new Error("temporary failure");
renderTab();
expect(screen.getByText(/packets heard per 15 min/i)).toBeInTheDocument();
expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
it("shows one empty card instead of flat charts when nothing was heard", () => {
activityResult.data = { ...activity, payloadTypes: [], points: [] };
renderTab();
expect(screen.getByText(/no packets heard/i)).toBeInTheDocument();
expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument();
});
});
describe("Observer dashboard hierarchy", () => {
it("uses period metrics rather than the legacy presence counter", () => {
activityResult.data = {
...activity,
windowStart: Date.now() - 86400000,
windowEnd: Date.now(),
generatedAt: Date.now(),
source: "raw",
summary: {
recordedPackets: 9,
lastCompleteHour: 2,
lastCompleteHourStart: Date.now() - 7200000,
lastCompleteHourEnd: Date.now() - 3600000,
latestRecordedAt: Date.now() - 60000,
},
};
renderTab();
const cards = screen.getByRole("list", { name: "Observer metrics" });
expect(within(cards).getByText("9")).toBeInTheDocument();
expect(within(cards).getAllByRole("listitem")).toHaveLength(6);
expect(within(cards).queryByText("12")).not.toBeInTheDocument();
expect(
screen.getByText("Packets heard per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("does not invent zero packet metrics on an older server", () => {
renderTab();
expect(screen.getByText(/Packet totals unavailable/)).toBeInTheDocument();
});
it("provides French monitoring labels", async () => {
await i18n.changeLanguage("fr");
renderTab();
expect(screen.getByText("Paquets reçus")).toBeInTheDocument();
expect(screen.getByText("Temps radio TX / RX · 24 h")).toBeInTheDocument();
expect(screen.queryByText("Détails de l’appareil")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Copier la clé publique/ })).toBeInTheDocument();
});
it("names the busy series in French", async () => {
await i18n.changeLanguage("fr");
renderTab();
const options = screen.getAllByTestId("chart").map((c) => c.getAttribute("data-option") ?? "");
expect(options.some((o) => o.includes('"name":"Occupation"'))).toBe(true);
expect(options.some((o) => o.includes('"name":"Busy"'))).toBe(false);
});
it("keeps packet metrics without the removed traffic text badge", () => {
const now = Date.now();
observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }];
activityResult.data = {
...activity,
summary: {
recordedPackets: 9,
lastCompleteHour: 2,
lastCompleteHourStart: now - 7200000,
lastCompleteHourEnd: now - 3600000,
latestRecordedAt: now - 3600000,
},
};
renderTab();
expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument();
expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument();
});
it("omits the duplicate observer picker and marked explanatory sections", () => {
renderTab();
expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument();
expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument();
expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument();
});
});