mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 09:52:29 +00:00
feat(stats): compare flood packets heard by two observers (#48)
This commit is contained in:
1 parent
d5d9c0870c
commit
88e2c0f319
7 files changed
+321
-6
No files matched your search
+11
-2
@@ -6,6 +6,7 @@ import type { ObserverSummary, Observer, AdvertObservation } from "../features/o
|
||||
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
|
||||
import type {
|
||||
StatsOverview,
|
||||
ObserverComparison,
|
||||
ObservationPoint,
|
||||
PayloadBreakdownItem,
|
||||
TopNode,
|
||||
@@ -47,7 +48,7 @@ async function errorFrom(res: Response): Promise<ApiError> {
|
||||
return new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText, retryAfterMs);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||
async function request<T>(path: string, params?: Record<string, string | number | undefined>, signal?: AbortSignal): Promise<T> {
|
||||
const url = new URL(`${API_BASE}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -57,7 +58,7 @@ async function request<T>(path: string, params?: Record<string, string | number
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const res = await (signal ? fetch(url.toString(), { signal }) : fetch(url.toString()));
|
||||
|
||||
if (!res.ok) throw await errorFrom(res);
|
||||
noteRequestOk();
|
||||
@@ -67,6 +68,14 @@ async function request<T>(path: string, params?: Record<string, string | number
|
||||
|
||||
// endpoint functions
|
||||
|
||||
export function getObserverComparison(
|
||||
iatas: string[] | undefined,
|
||||
params: { observerA: string; observerB: string; since: number; until: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<ObserverComparison> {
|
||||
return request("/stats/observer-comparison", { ...params, iatas: iatasParam(iatas) }, signal);
|
||||
}
|
||||
|
||||
// The region filter travels as the comma-separated `iatas` param; undefined/empty means all regions.
|
||||
function iatasParam(iatas?: string[]): string | undefined {
|
||||
return iatas && iatas.length > 0 ? iatas.join(",") : undefined;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { getObserver, getObserverComparison, getObserversPage } from "../../api/client";
|
||||
import { useRegion } from "../../hooks/useRegion";
|
||||
import { Card } from "./cards";
|
||||
|
||||
type Selection = { observerA: string; observerB: string; since: number; until: number };
|
||||
const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
|
||||
const fieldClass = "min-w-0 w-full rounded border border-border bg-bg-base px-2.5 py-2 text-base text-text-normal";
|
||||
|
||||
function validation(value: Selection): string | null {
|
||||
if (!uuid.test(value.observerA) || !uuid.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) {
|
||||
return "Choose two different observers.";
|
||||
}
|
||||
if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) {
|
||||
return "Choose a valid start and a later end time.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function localTime(ms: number) {
|
||||
const date = new Date(ms);
|
||||
return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 23);
|
||||
}
|
||||
|
||||
function ObserverSelect({ label, value, onChange }: { label: string; value: string; onChange: (id: string) => void }) {
|
||||
const { iatas, regionKey } = useRegion();
|
||||
const [search, setSearch] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setName(search.trim()), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
const options = useQuery({
|
||||
queryKey: ["comparison-observers", regionKey, name],
|
||||
queryFn: () => getObserversPage(iatas, { name: name || undefined, limit: 50 }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const selected = useQuery({
|
||||
queryKey: ["observer", value], queryFn: () => getObserver(value),
|
||||
enabled: uuid.test(value), staleTime: 30_000, retry: false,
|
||||
});
|
||||
const rows = options.data?.items ?? [];
|
||||
return (
|
||||
<fieldset className="min-w-0 space-y-2">
|
||||
<legend className="mb-2 text-sm text-text-muted">{label}</legend>
|
||||
<input type="search" aria-label={`Search ${label}`} placeholder="Search all observers by name…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} className={fieldClass} />
|
||||
<select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className={fieldClass}>
|
||||
<option value="">Choose an observer</option>
|
||||
{value && !rows.some((o) => o.id === value) && <option value={value}>{selected.data?.displayName ?? value}</option>}
|
||||
{rows.map((o) => <option key={o.id} value={o.id}>{o.displayName ?? o.id.slice(0, 8)} · {o.iata} · {o.id.slice(0, 8)}</option>)}
|
||||
</select>
|
||||
<p className="text-xs text-text-dim" role="status">
|
||||
{options.isPending ? "Loading observers…" : options.isError ? "Observer search failed. Try again." : options.data?.hasMore ? "Showing 50 observers. Search to find others." : rows.length === 0 ? "No observers match this search." : "Search includes all matching observers in the selected region."}
|
||||
</p>
|
||||
{options.isError && <button type="button" onClick={() => void options.refetch()} className="text-sm text-primary">Retry observer search</button>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onCompare: (value: Selection) => void }) {
|
||||
const [a, setA] = useState(initial?.observerA ?? "");
|
||||
const [b, setB] = useState(initial?.observerB ?? "");
|
||||
const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000));
|
||||
const [until, setUntil] = useState(() => localTime(initial?.until ?? Math.floor(Date.now() / 60_000) * 60_000));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = { observerA: a.toLowerCase(), observerB: b.toLowerCase(), since: new Date(since).getTime(), until: new Date(until).getTime() };
|
||||
const message = validation(value);
|
||||
setError(message);
|
||||
if (!message) onCompare(value);
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4" noValidate>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ObserverSelect label="Observer A" value={a} onChange={setA} />
|
||||
<ObserverSelect label="Observer B" value={b} onChange={setB} />
|
||||
<label className="min-w-0 space-y-2 text-sm text-text-muted"><span>Start (local time)</span>
|
||||
<input type="datetime-local" step="0.001" value={since} onChange={(e) => setSince(e.target.value)} className={fieldClass} />
|
||||
</label>
|
||||
<label className="min-w-0 space-y-2 text-sm text-text-muted"><span>End (local time)</span>
|
||||
<input type="datetime-local" step="0.001" value={until} onChange={(e) => setUntil(e.target.value)} className={fieldClass} />
|
||||
</label>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
|
||||
<button type="submit" className="rounded border border-primary-dim bg-primary/10 px-4 py-2 text-sm font-semibold text-primary">Compare</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompareObserversTab() {
|
||||
const { iatas, regionKey } = useRegion();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const keys = ["compareA", "compareB", "compareSince", "compareUntil"];
|
||||
const supplied = keys.some((key) => params.has(key));
|
||||
const parsed: Selection = {
|
||||
observerA: params.get("compareA") ?? "", observerB: params.get("compareB") ?? "",
|
||||
since: Number(params.get("compareSince")), until: Number(params.get("compareUntil")),
|
||||
};
|
||||
const valid = keys.every((key) => params.getAll(key).length === 1 && params.get(key) !== "") && !validation(parsed);
|
||||
const selection = valid ? parsed : null;
|
||||
const result = useQuery({
|
||||
queryKey: ["observer-comparison", regionKey, selection],
|
||||
queryFn: ({ signal }) => getObserverComparison(iatas, selection!, signal),
|
||||
enabled: selection !== null,
|
||||
retry: false, staleTime: 30_000, refetchOnWindowFocus: false,
|
||||
});
|
||||
const observerA = useQuery({
|
||||
queryKey: ["observer", selection?.observerA], queryFn: () => getObserver(selection!.observerA),
|
||||
enabled: selection !== null, staleTime: 30_000, retry: false,
|
||||
});
|
||||
const observerB = useQuery({
|
||||
queryKey: ["observer", selection?.observerB], queryFn: () => getObserver(selection!.observerB),
|
||||
enabled: selection !== null, staleTime: 30_000, retry: false,
|
||||
});
|
||||
function compare(value: Selection) {
|
||||
if (selection && Object.entries(value).every(([key, v]) => selection[key as keyof Selection] === v)) {
|
||||
void result.refetch();
|
||||
return;
|
||||
}
|
||||
setParams((old) => {
|
||||
const next = new URLSearchParams(old);
|
||||
next.set("compareA", value.observerA); next.set("compareB", value.observerB);
|
||||
next.set("compareSince", String(value.since)); next.set("compareUntil", String(value.until));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
const data = result.data;
|
||||
const groups = data ? [
|
||||
{ name: "Only A", count: data.onlyA, color: "var(--color-primary)" },
|
||||
{ name: "Both", count: data.both, color: "var(--color-green)" },
|
||||
{ name: "Only B", count: data.onlyB, color: "var(--color-secondary)" },
|
||||
] : [];
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4">
|
||||
<Card title="Compare observers">
|
||||
<p className="mb-4 text-sm text-text-muted">Compare distinct flood packets reported by two observers over any retained time period.</p>
|
||||
{supplied && !valid && <p role="alert" className="mb-3 text-sm text-danger">This comparison link has invalid or missing values. Choose observers and dates below.</p>}
|
||||
<ComparisonForm key={keys.map((key) => params.get(key)).join("|")} initial={selection} onCompare={compare} />
|
||||
</Card>
|
||||
{selection && <Card title="Reported flood packets">
|
||||
<p className="mb-2 break-words text-sm text-text-normal">A: {observerA.data?.displayName ?? selection.observerA} · B: {observerB.data?.displayName ?? selection.observerB}</p>
|
||||
<p className="mb-3 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} to {new Date(selection.until).toLocaleString()} (local time; end excluded). Region: {iatas?.join(", ") || "All"}.</p>
|
||||
{result.isFetching && <p role="status" className="text-sm text-text-muted">Comparing reported packets…</p>}
|
||||
{result.isError && <div role="alert" className="text-sm text-danger"><p>{result.error.message}</p><button type="button" onClick={() => void result.refetch()} className="mt-2 text-primary">Retry comparison</button></div>}
|
||||
{data && !result.isError && <>
|
||||
<p className="mb-3 text-lg font-semibold text-text-bright">{data.totalPackets.toLocaleString()} distinct flood packets</p>
|
||||
{data.totalPackets === 0 ? <p className="text-sm text-text-muted">No flood packets were reported by either observer in this period and region.</p> : <>
|
||||
<div aria-hidden className="mb-4 flex h-5 overflow-hidden rounded">{groups.map((g) => <div key={g.name} style={{ width: `${g.count / data.totalPackets * 100}%`, background: g.color }} />)}</div>
|
||||
<table className="w-full text-left text-sm tabular-nums" aria-label="Flood packet comparison">
|
||||
<thead className="text-text-muted"><tr><th scope="col">Heard by</th><th scope="col" className="text-right">Packets</th><th scope="col" className="text-right">% of union</th></tr></thead>
|
||||
<tbody>{groups.map((g) => <tr key={g.name} className="border-t border-border"><th scope="row" className="py-2 font-normal text-text-normal"><span aria-hidden className="mr-2 inline-block h-2 w-2 rounded-full" style={{ background: g.color }} />{g.name}</th><td className="text-right">{g.count.toLocaleString()}</td><td className="text-right">{(g.count / data.totalPackets * 100).toFixed(1)}%</td></tr>)}</tbody>
|
||||
</table>
|
||||
</>}
|
||||
<p className="mt-4 text-xs text-text-dim">Percentages use the union of packets heard by either observer. Repeated receptions count once. These counts reflect retained reports, not radio packet loss; an offline observer, broker interruption or expired history can affect the result.</p>
|
||||
</>}
|
||||
</Card>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { MeshTab } from "./MeshTab";
|
||||
import { TalkersTab } from "./TalkersTab";
|
||||
import { ClockDriftTab } from "./ClockDriftTab";
|
||||
import { ObserverTab } from "./ObserverTab";
|
||||
import { CompareObserversTab } from "./CompareObserversTab";
|
||||
import { NeighbourGraphTab } from "./NeighbourGraphTab";
|
||||
import type { StatsRange, StatsTab } from "./types";
|
||||
|
||||
const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "graph"];
|
||||
const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "compare", "graph"];
|
||||
const RANGES: StatsRange[] = ["24h", "7d", "30d"];
|
||||
|
||||
const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh");
|
||||
@@ -60,6 +61,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
|
||||
<ObserverTab range={range} selectedObserverId={observerId} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
|
||||
)}
|
||||
{tab === "graph" && <NeighbourGraphTab />}
|
||||
{tab === "compare" && <CompareObserversTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ const TAB_OPTIONS = [
|
||||
{ value: "talkers", label: "Talkers", icon: <TalkersIcon /> },
|
||||
{ value: "clockdrift", label: "Clock Drift", icon: <ClockDriftIcon /> },
|
||||
{ value: "observer", label: "Observer", icon: <ObserverIcon /> },
|
||||
{ value: "compare", label: "Compare observers", icon: <ObserverIcon /> },
|
||||
{ value: "graph", label: "Neighbour Graph", icon: <GraphIcon /> },
|
||||
];
|
||||
|
||||
@@ -103,8 +104,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* graph is topology and clock-drift is each node's latest reading — neither is time-windowed */}
|
||||
{tab !== "graph" && tab !== "clockdrift" && (
|
||||
{/* Comparison has explicit dates; graph and clock drift have no rolling window. */}
|
||||
{tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && (
|
||||
<Segmented
|
||||
className="shrink-0"
|
||||
options={RANGE_OPTIONS}
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
|
||||
import type { NodeIATA } from "../nodes/types";
|
||||
|
||||
// beacon-server /stats/observer-comparison: disjoint groups of distinct flood
|
||||
// packet hashes, selected by reception time in [since, until).
|
||||
export interface ObserverComparison {
|
||||
observerA: string;
|
||||
observerB: string;
|
||||
since: number;
|
||||
until: number;
|
||||
totalPackets: number;
|
||||
onlyA: number;
|
||||
onlyB: number;
|
||||
both: number;
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
totalPackets: number;
|
||||
totalObservations: number;
|
||||
@@ -140,7 +153,7 @@ export interface ObserverActivity {
|
||||
}
|
||||
|
||||
// Sub-tab + time-range identifiers shared across the Stats page.
|
||||
export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "graph";
|
||||
export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
|
||||
export type StatsRange = "24h" | "7d" | "30d";
|
||||
|
||||
export const RANGE_MS: Record<StatsRange, number> = {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { getObserverComparison } from "../../src/api/client";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("sends the complete comparison scope, including an epoch-zero start, and consumes cancellation", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ totalPackets: 0 }) });
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const controller = new AbortController();
|
||||
await getObserverComparison(["YVR", "YYJ"], { observerA: "a", observerB: "b", since: 0, until: 1000 }, controller.signal);
|
||||
const [url, options] = fetcher.mock.calls[0];
|
||||
expect(new URL(url).pathname).toContain("/stats/observer-comparison");
|
||||
expect(Object.fromEntries(new URL(url).searchParams)).toEqual({ observerA: "a", observerB: "b", since: "0", until: "1000", iatas: "YVR,YYJ" });
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
expect(options.signal.aborted).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
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";
|
||||
|
||||
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 }));
|
||||
|
||||
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>; }
|
||||
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("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => {
|
||||
mount();
|
||||
await screen.findAllByRole("option", { name: /Rooftop/ });
|
||||
expect(getObserverComparison).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } });
|
||||
fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } });
|
||||
fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } });
|
||||
fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-02-15T00: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-02-15T00: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.getByText(/Percentages use the union/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects equal observers without sending an expensive query", async () => {
|
||||
mount();
|
||||
await screen.findAllByRole("option", { name: /Rooftop/ });
|
||||
fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } });
|
||||
fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: a } });
|
||||
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(/No flood packets were reported/)).toBeInTheDocument();
|
||||
expect(document.body).not.toHaveTextContent("NaN");
|
||||
});
|
||||
|
||||
it("searches the server beyond the initial observer page", async () => {
|
||||
mount();
|
||||
fireEvent.change(screen.getByLabelText("Search 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("Observer A"), { target: { value: b } });
|
||||
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/);
|
||||
await screen.findAllByRole("option", { name: /Rooftop/ });
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user