From dce4e49900c081ba00f5c3b53e32680a5c83b104 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 25 Sep 2026 11:21:07 -0400 Subject: [PATCH] fix(stats): hide unavailable Talkers results (#68) --- src/features/stats/TalkersTab.tsx | 13 ++- tests/features/stats/TalkersTab.test.tsx | 120 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/features/stats/TalkersTab.test.tsx diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 0ef7587..6caf0f1 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -26,7 +26,10 @@ export function TalkersTab({ range }: TalkersTabProps) { const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const advertisers = topAdvertisers.data ?? []; + const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isLoading || topAdvertisers.isPlaceholderData; + const talkersLoading = topTalkers.isPending || topTalkers.isLoading || topTalkers.isPlaceholderData; + const talkersUnavailable = talkersLoading || topTalkers.isError; + const advertisers = advertisersLoading || topAdvertisers.isError ? [] : (topAdvertisers.data ?? []); const advertiserColumns = useMemo[]>(() => { const windowMs = RANGE_MS[range]; @@ -56,8 +59,8 @@ export function TalkersTab({ range }: TalkersTabProps) { }, [range]); const talkerRows = useMemo( - () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), - [topTalkers.data, colors], + () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), + [topTalkers.data, colors, talkersUnavailable], ); const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); @@ -71,7 +74,7 @@ export function TalkersTab({ range }: TalkersTabProps) { rowKey={(a) => a.nodeId} selectedKey={null} onSelect={() => {}} - isLoading={topAdvertisers.isLoading} + isLoading={advertisersLoading} emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"} /> @@ -81,7 +84,7 @@ export function TalkersTab({ range }: TalkersTabProps) { right={by name} height={leaderboardHeight(talkerRows.length)} option={talkersOption} - isLoading={topTalkers.isLoading} + isLoading={talkersLoading} isError={topTalkers.isError} isEmpty={talkerRows.length === 0} /> diff --git a/tests/features/stats/TalkersTab.test.tsx b/tests/features/stats/TalkersTab.test.tsx new file mode 100644 index 0000000..f983ba7 --- /dev/null +++ b/tests/features/stats/TalkersTab.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TalkersTab } from "../../../src/features/stats/TalkersTab"; +import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client"; +import type { StatsRange, TopAdvertiser, TopTalker } from "../../../src/features/stats/types"; +import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; + +const region = { iatas: ["YVR"], regionKey: "YVR" }; +const advertisers: TopAdvertiser[] = [{ nodeId: "fixture-node", nodeName: "Old advertiser", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", advertCount: 14, floodAdvertCount: 10, directAdvertCount: 4, lastHeard: 0 }]; +const talkers: TopTalker[] = [{ senderName: "Old sender", messageCount: 42, lastSent: 0 }]; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getTopAdvertisers: vi.fn(), getTopTalkers: vi.fn() })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: EChartsOption }) => { + const axis = Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis; + return
{axis?.data?.map(String).join(" ")}
; +} })); + +const clients: QueryClient[] = []; +beforeEach(() => { + vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; + vi.mocked(getTopAdvertisers).mockReset().mockResolvedValue(advertisers); + vi.mocked(getTopTalkers).mockReset().mockResolvedValue(talkers); +}); +afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function mount() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + const view = (range: StatsRange) => ; + const result = render(view("24h")); + return { client, rerender: (range: StatsRange) => result.rerender(view(range)) }; +} + +it.each(["region", "range"] as const)("hides previous results while the new %s is pending, then shows each resolved panel", async (filter) => { + const { rerender } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + const nextAdvertisers = deferred(), nextTalkers = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(nextAdvertisers.promise); + vi.mocked(getTopTalkers).mockReturnValue(nextTalkers.promise); + if (filter === "region") { region.iatas = ["YOW"]; region.regionKey = "YOW"; } + const requestedAt = Date.now(); + rerender(filter === "range" ? "7d" : "24h"); + await waitFor(() => expect(getTopAdvertisers).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); + expect(screen.queryByText("Old sender")).not.toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => nextAdvertisers.resolve([{ ...advertisers[0]!, nodeName: "New advertiser" }])); + await screen.findByText("New advertiser"); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => nextTalkers.resolve([{ ...talkers[0]!, senderName: "New sender" }])); + await screen.findByText("New sender"); + const request = vi.mocked(getTopAdvertisers).mock.calls[1]!; + expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]); + expect(request[2]).toBe(20); + const windowMs = (filter === "range" ? 7 : 1) * 86_400_000; + expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs); + expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs); +}); + +it("shows an advertiser refetch error instead of cached rows, keeps the sender panel, and recovers", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + vi.mocked(getTopAdvertisers).mockRejectedValue(new Error("controlled advertiser failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); + await screen.findByText("Failed to load"); + expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); + expect(screen.getByText("Old sender")).toBeInTheDocument(); + expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers); + vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]); + await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); + await screen.findByText("Recovered advertiser"); + expect(screen.queryByText("Failed to load")).not.toBeInTheDocument(); + expect(getTopTalkers).toHaveBeenCalledOnce(); +}); + +it("keeps advertisers usable when only the sender request fails", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + vi.mocked(getTopTalkers).mockRejectedValue(new Error("controlled sender failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-top-talkers"] })); + await screen.findByText("Failed to load"); + expect(screen.queryByText("Old sender")).not.toBeInTheDocument(); + expect(screen.getByText("Old advertiser")).toBeInTheDocument(); + expect(getTopAdvertisers).toHaveBeenCalledOnce(); +}); + +it("retains valid rows and rates during an ordinary background refresh", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + const next = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(next.promise); + let refresh!: Promise; + await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); }); + expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching"); + expect(screen.getByText("Old advertiser")).toBeInTheDocument(); + expect(screen.getByText("10/d")).toBeInTheDocument(); + expect(screen.getByText("4/d")).toBeInTheDocument(); + expect(screen.getByText("Old sender")).toBeInTheDocument(); + await act(async () => { next.resolve([{ ...advertisers[0]!, nodeName: "Fresh advertiser" }]); await refresh; }); + await screen.findByText("Fresh advertiser"); +}); + +it("distinguishes initial loading from successful empty results", async () => { + const nextAdvertisers = deferred(), nextTalkers = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(nextAdvertisers.promise); + vi.mocked(getTopTalkers).mockReturnValue(nextTalkers.promise); + mount(); + expect(screen.queryByText("No advertisers")).not.toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => { nextAdvertisers.resolve([]); nextTalkers.resolve([]); }); + await screen.findByText("No advertisers"); + expect(screen.getByText("No data")).toBeInTheDocument(); +});