Handle empty usage responses without leaving the panel in its loading skeleton. - add a hasFetched flag to useUsageData so the UI can distinguish initial loading from an empty completed response - update UsageIndicator to render the skeleton only before the first fetch completes and show the empty state afterward - refresh dashboard hook and component tests to cover empty fetches, refresh behavior, and the new loading semantics Files changed: .../dashboard/app/components/UsageIndicator.tsx | 19 +- .../components/__tests__/UsageIndicator.test.tsx | 395 +++++++++++---------- .../app/hooks/__tests__/useUsageData.test.ts | 42 ++- packages/dashboard/app/hooks/useUsageData.ts | 5 + 4 files changed, 245 insertions(+), 216 deletions(-) Fusion-Task-Id: FN-5886 Fusion-Task-Lineage: 8fa901a9-3167-4e33-a082-e5de95f692da
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { useUsageData } from "../useUsageData";
|
|
import * as api from "../../api";
|
|
|
|
describe("useUsageData", () => {
|
|
const mockFetchUsageData = vi.spyOn(api, "fetchUsageData");
|
|
|
|
beforeEach(() => {
|
|
mockFetchUsageData.mockReset();
|
|
});
|
|
|
|
it("fetches data on initial mount", async () => {
|
|
const mockData = {
|
|
providers: [
|
|
{
|
|
name: "Claude",
|
|
icon: "🟠",
|
|
status: "ok" as const,
|
|
windows: [],
|
|
},
|
|
],
|
|
};
|
|
mockFetchUsageData.mockResolvedValue(mockData);
|
|
|
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
|
|
expect(result.current.loading).toBe(true);
|
|
expect(result.current.providers).toEqual([]);
|
|
expect(result.current.hasFetched).toBe(false);
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
expect(result.current.providers).toEqual(mockData.providers);
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
|
expect(result.current.hasFetched).toBe(true);
|
|
});
|
|
|
|
it("handles fetch errors", async () => {
|
|
mockFetchUsageData.mockRejectedValue(new Error("Network error"));
|
|
|
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
|
|
expect(result.current.hasFetched).toBe(false);
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
expect(result.current.error).toBe("Network error");
|
|
expect(result.current.providers).toEqual([]);
|
|
expect(result.current.hasFetched).toBe(true);
|
|
});
|
|
|
|
it("manual refresh fetches new data", async () => {
|
|
const mockData1 = {
|
|
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
};
|
|
const mockData2 = {
|
|
providers: [{ name: "Codex", icon: "🟢", status: "ok" as const, windows: [] }],
|
|
};
|
|
|
|
mockFetchUsageData
|
|
.mockResolvedValueOnce(mockData1)
|
|
.mockResolvedValueOnce(mockData2);
|
|
|
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
expect(result.current.providers).toEqual(mockData1.providers);
|
|
expect(result.current.hasFetched).toBe(true);
|
|
|
|
await act(async () => {
|
|
await result.current.refresh();
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.providers).toEqual(mockData2.providers));
|
|
expect(result.current.hasFetched).toBe(true);
|
|
});
|
|
|
|
it("clears error on successful manual refresh after error", async () => {
|
|
mockFetchUsageData
|
|
.mockRejectedValueOnce(new Error("Network error"))
|
|
.mockResolvedValueOnce({
|
|
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
|
});
|
|
|
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
expect(result.current.error).toBe("Network error");
|
|
expect(result.current.hasFetched).toBe(true);
|
|
|
|
await act(async () => {
|
|
await result.current.refresh();
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.error).toBeNull());
|
|
expect(result.current.providers).toHaveLength(1);
|
|
expect(result.current.hasFetched).toBe(true);
|
|
});
|
|
|
|
it("sets hasFetched to true after a successful empty fetch", async () => {
|
|
mockFetchUsageData.mockResolvedValue({ providers: [] });
|
|
|
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
|
|
expect(result.current.hasFetched).toBe(false);
|
|
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
expect(result.current.providers).toEqual([]);
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.hasFetched).toBe(true);
|
|
});
|
|
|
|
it("exports the correct interface", () => {
|
|
expect(typeof useUsageData).toBe("function");
|
|
});
|
|
|
|
it("returns expected default values before first fetch", () => {
|
|
mockFetchUsageData.mockImplementation(() => new Promise(() => {}));
|
|
|
|
const { result } = renderHook(() => useUsageData({ autoRefresh: false }));
|
|
|
|
expect(result.current.providers).toEqual([]);
|
|
expect(result.current.loading).toBe(true);
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.lastUpdated).toBeNull();
|
|
expect(result.current.hasFetched).toBe(false);
|
|
expect(typeof result.current.refresh).toBe("function");
|
|
});
|
|
});
|