mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-20 08:31:56 +00:00
152 lines
8.8 KiB
TypeScript
152 lines
8.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
|
import { CompareObserversTab } from "../../../src/features/stats/CompareObserversTab";
|
|
import { getObserverComparison, getObserversPage, getObserver } from "../../../src/api/client";
|
|
import i18n from "../../../src/i18n";
|
|
|
|
const region = { iatas: ["YVR"], regionKey: "YVR" };
|
|
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
|
|
vi.mock("../../../src/api/client", () => ({
|
|
getObserverComparison: vi.fn(), getObserversPage: vi.fn(), getObserver: vi.fn(),
|
|
}));
|
|
vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => null }));
|
|
// resetAllMocks below also wipes the global matchMedia stub the tooltip hover check reads
|
|
vi.mock("../../../src/hooks/useMediaQuery", async (orig) => ({ ...(await orig<object>()), useHasHover: () => true }));
|
|
|
|
const a = "11111111-1111-1111-1111-111111111111";
|
|
const b = "22222222-2222-2222-2222-222222222222";
|
|
const query = `?tab=Analytics&statsTab=compare&compareA=${a}&compareB=${b}&compareSince=1000&compareUntil=2000`;
|
|
const counts = { observerA: a, observerB: b, since: 1000, until: 2000, totalPackets: 4, onlyA: 1, onlyB: 2, both: 1 };
|
|
|
|
function Location() { return <output aria-label="Current URL">{useLocation().search}</output>; }
|
|
async function pick(label: string, name: RegExp) {
|
|
fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${label}:`) }));
|
|
fireEvent.click(await within(screen.getByRole("listbox", { name: label })).findByRole("option", { name }));
|
|
}
|
|
function mount(url = "?statsTab=compare") {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
|
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[url]}><CompareObserversTab /><Location /></MemoryRouter></QueryClientProvider>);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
region.iatas = ["YVR"]; region.regionKey = "YVR";
|
|
vi.mocked(getObserversPage).mockResolvedValue({ items: [
|
|
{ id: a, displayName: "Rooftop", iata: "YVR", status: "online" },
|
|
{ id: b, displayName: "Hilltop", iata: "YVR", status: "online" },
|
|
], hasMore: false, nextCursor: null });
|
|
vi.mocked(getObserver).mockImplementation(async (id) => ({ id, displayName: id === a ? "Rooftop" : "Hilltop", iata: "YVR", status: "online", publicKey: "00", firstSeen: 0, lastSeen: 0, observationCount: 0, brokers: [] }));
|
|
vi.mocked(getObserverComparison).mockResolvedValue(counts);
|
|
});
|
|
|
|
describe("observer comparison", () => {
|
|
it("accepts a comparison longer than three days", async () => {
|
|
mount();
|
|
await pick("Observer A", /Rooftop/);
|
|
await pick("Observer B", /Hilltop/);
|
|
fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } });
|
|
fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-05T00:00" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Compare" }));
|
|
await waitFor(() => expect(getObserverComparison).toHaveBeenCalled());
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
});
|
|
it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => {
|
|
mount();
|
|
expect(getObserverComparison).not.toHaveBeenCalled();
|
|
await pick("Observer A", /Rooftop/);
|
|
await pick("Observer B", /Hilltop/);
|
|
fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } });
|
|
fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-03T00:00" } });
|
|
expect(getObserverComparison).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole("button", { name: "Compare" }));
|
|
await waitFor(() => expect(getObserverComparison).toHaveBeenCalledWith(["YVR"], {
|
|
observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-01-03T00:00").getTime(),
|
|
}, expect.any(AbortSignal)));
|
|
expect(screen.getByLabelText("Current URL").textContent).toContain(`compareA=${a}`);
|
|
});
|
|
|
|
it("restores a shared comparison and shows disjoint counts and union percentages", async () => {
|
|
mount(query);
|
|
const table = await screen.findByRole("table", { name: "Flood packet comparison" });
|
|
expect(within(table).getByRole("row", { name: /Only A.*1.*25.0%/ })).toBeInTheDocument();
|
|
expect(within(table).getByRole("row", { name: /Only B.*2.*50.0%/ })).toBeInTheDocument();
|
|
expect(within(table).getByRole("row", { name: /Both.*1.*25.0%/ })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Percentages are of all flood packets/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("offers only a different observer and rejects an incomplete pair without querying", async () => {
|
|
mount();
|
|
await pick("Observer A", /Rooftop/);
|
|
fireEvent.click(screen.getByRole("button", { name: /^Observer B:/ }));
|
|
const list = screen.getByRole("listbox", { name: "Observer B" });
|
|
await within(list).findByRole("option", { name: /Hilltop/ });
|
|
expect(within(list).queryByRole("option", { name: /Rooftop/ })).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Compare" }));
|
|
expect(screen.getByRole("alert")).toHaveTextContent(/different observers/);
|
|
expect(getObserverComparison).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders an empty period without NaN percentages", async () => {
|
|
vi.mocked(getObserverComparison).mockResolvedValue({ ...counts, totalPackets: 0, onlyA: 0, onlyB: 0, both: 0 });
|
|
mount(query);
|
|
expect(await screen.findByText(/Neither observer heard a flood packet/)).toBeInTheDocument();
|
|
expect(document.body).not.toHaveTextContent("NaN");
|
|
});
|
|
|
|
it("searches the server beyond the initial observer page", async () => {
|
|
mount();
|
|
fireEvent.click(screen.getByRole("button", { name: /^Observer A:/ }));
|
|
fireEvent.change(screen.getByRole("searchbox", { name: /Observer A/ }), { target: { value: "Distant station" } });
|
|
await waitFor(() => expect(getObserversPage).toHaveBeenCalledWith(["YVR"], { name: "Distant station", limit: 50 }));
|
|
});
|
|
|
|
it("keeps applied result labels when the draft selection changes", async () => {
|
|
mount(query);
|
|
await screen.findByRole("table", { name: "Flood packet comparison" });
|
|
expect(await screen.findByText("A: Rooftop · B: Hilltop")).toBeInTheDocument();
|
|
fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-02T00:00" } });
|
|
expect(screen.getByText("A: Rooftop · B: Hilltop")).toBeInTheDocument();
|
|
expect(getObserverComparison).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects malformed shared dates without issuing a comparison", async () => {
|
|
mount(query.replace("compareUntil=2000", "compareUntil=bad"));
|
|
expect(screen.getByRole("alert")).toHaveTextContent(/invalid or missing/);
|
|
expect(screen.getByRole("button", { name: /^Observer A:/ })).toBeInTheDocument();
|
|
await waitFor(() => expect(getObserverComparison).not.toHaveBeenCalled());
|
|
});
|
|
|
|
it("shows query failures and retries only when requested", async () => {
|
|
vi.mocked(getObserverComparison).mockRejectedValueOnce(new Error("comparison timed out; try a shorter time period"));
|
|
mount(query);
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(/shorter time period/);
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry comparison" }));
|
|
await screen.findByRole("table", { name: "Flood packet comparison" });
|
|
expect(getObserverComparison).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses minute precision and flags a start older than packet retention", async () => {
|
|
mount();
|
|
const start = screen.getByLabelText("Start (local time)");
|
|
expect(start).toHaveAttribute("step", "60");
|
|
expect(screen.getByLabelText("End (local time)")).toHaveAttribute("step", "60");
|
|
expect((start as HTMLInputElement).value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
expect(screen.queryByText(/Only recent packets are kept/)).not.toBeInTheDocument();
|
|
fireEvent.change(start, { target: { value: "2020-01-01T00:00" } });
|
|
expect(screen.getByText(/Only recent packets are kept/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the comparison in French", async () => {
|
|
await i18n.changeLanguage("fr");
|
|
mount(query);
|
|
const table = await screen.findByRole("table", { name: "Comparaison des paquets flood" });
|
|
expect(within(table).getByRole("row", { name: /A seulement.*1.*25.0%/ })).toBeInTheDocument();
|
|
expect(screen.queryByText("Comparer les observateurs")).not.toBeInTheDocument();
|
|
expect(screen.getByText("A : Rooftop · B : Hilltop")).toBeInTheDocument();
|
|
expect(screen.getByText(/4 paquets flood/)).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Comparer" })).toBeInTheDocument();
|
|
});
|
|
});
|