import { focusManager, onlineManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import type { Address } from "viem"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard"; import { useBankDashboard } from "./useBankDashboard"; let notifyBlock: ((blockNumber: bigint) => void) | undefined; vi.mock("wagmi", () => ({ useWatchBlockNumber: (options: { onBlockNumber?: (blockNumber: bigint) => void }) => { notifyBlock = options.onBlockNumber; }, })); const address = (digit: string) => `0x${digit.repeat(40)}` as Address; const manifest: DeploymentManifest = { schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3n, rpcUrl: "http://127.0.0.1:8545", token: address("1"), proxy: address("2"), implementation: address("3"), owner: address("4"), actors: [{ label: "Alice", address: address("5") }], }; const manifestJson = { ...manifest, deploymentBlock: 3 }; const snapshot: DashboardSnapshot = { blockNumber: 12n, synchronizedAt: new Date("2026-08-21T10:00:00.000Z"), version: 1, paused: false, asset: manifest.token, owner: manifest.owner, proxy: manifest.proxy, implementation: manifest.implementation, reserves: 900_000_000n, liabilities: 900_000_000n, surplus: 0n, actors: [{ label: "Alice", address: address("5"), balance: 900_000_000n }], activity: [], diagnostics: [], }; function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function createWrapper(client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })) { return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe("useBankDashboard", () => { beforeEach(() => { notifyBlock = undefined; focusManager.setFocused(true); onlineManager.setOnline(true); }); afterEach(() => { focusManager.setFocused(undefined); onlineManager.setOnline(true); }); it("starts in loading while the manifest and first reconciled snapshot are pending", () => { const pendingManifest = deferred(); const { result } = renderHook(() => useBankDashboard({ loadManifest: () => pendingManifest.promise, loader: vi.fn(), }), { wrapper: createWrapper() }); expect(result.current.status).toBe("loading"); }); it("becomes ready with the synchronized block and time from the successful snapshot", async () => { const { result } = renderHook(() => useBankDashboard({ loadManifest: async () => manifestJson, loader: async () => snapshot, }), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.status).toBe("ready")); if (result.current.status !== "ready") throw new Error("expected ready dashboard"); expect(result.current.snapshot.blockNumber).toBe(12n); expect(result.current.snapshot.synchronizedAt.toISOString()).toBe("2026-08-21T10:00:00.000Z"); }); it("retains the last successful snapshot as stale when a later reconciliation fails", async () => { const loader = vi.fn() .mockResolvedValueOnce(snapshot) .mockRejectedValueOnce(new Error("RPC timeout")); const clock = vi.fn(() => new Date("2026-08-21T10:05:00.000Z")); const { result } = renderHook(() => useBankDashboard({ loadManifest: async () => manifestJson, loader, now: clock, }), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.status).toBe("ready")); act(() => notifyBlock?.(13n)); await waitFor(() => expect(result.current.status).toBe("stale")); if (result.current.status !== "stale") throw new Error("expected stale dashboard"); expect(result.current.snapshot).toBe(snapshot); expect(result.current.failedAt.toISOString()).toBe("2026-08-21T10:05:00.000Z"); expect(result.current.error).toContain("RPC timeout"); }); it("reports an initial RPC failure as disconnected without inventing a snapshot", async () => { const { result } = renderHook(() => useBankDashboard({ loadManifest: async () => manifestJson, loader: async () => { throw new Error("connection refused"); }, now: () => new Date("2026-08-21T10:06:00.000Z"), }), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.status).toBe("disconnected")); if (result.current.status !== "disconnected") throw new Error("expected disconnected dashboard"); expect(result.current.snapshot).toBeUndefined(); expect(result.current.error).toContain("connection refused"); }); it("makes an invalid manifest terminal and never calls the snapshot loader", async () => { const loader = vi.fn(); const { result } = renderHook(() => useBankDashboard({ loadManifest: async () => ({ network: "anvil" }), loader, }), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.status).toBe("invalid-manifest")); expect(loader).not.toHaveBeenCalled(); act(() => notifyBlock?.(14n)); expect(loader).not.toHaveBeenCalled(); }); it("makes a manifest/network mismatch terminal and stops block reconciliation", async () => { const loader = vi.fn().mockRejectedValue(new Error( "endpoint chain ID 84532 does not match manifest chain ID 31337", )); const { result } = renderHook(() => useBankDashboard({ loadManifest: async () => manifestJson, loader, }), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.status).toBe("chain-mismatch")); act(() => notifyBlock?.(14n)); await Promise.resolve(); expect(loader).toHaveBeenCalledTimes(1); }); it("does not retry a terminal manifest-fetch failure on focus, reconnect, or remount", async () => { const loadManifest = vi.fn().mockRejectedValue(new Error("manifest request failed")); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } } }); const wrapper = createWrapper(client); const options = { loadManifest, loader: vi.fn() }; const first = renderHook(() => useBankDashboard(options), { wrapper }); await waitFor(() => expect(first.result.current.status).toBe("invalid-manifest")); await act(async () => { focusManager.setFocused(false); focusManager.setFocused(true); onlineManager.setOnline(false); onlineManager.setOnline(true); await new Promise((resolve) => setTimeout(resolve, 0)); }); first.unmount(); const second = renderHook(() => useBankDashboard(options), { wrapper }); await waitFor(() => expect(second.result.current.status).toBe("invalid-manifest")); expect(loadManifest).toHaveBeenCalledTimes(1); }); it("does not retry a terminal chain mismatch on focus, reconnect, or remount", async () => { const loader = vi.fn().mockRejectedValue(new Error( "endpoint chain ID 84532 does not match manifest chain ID 31337", )); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } } }); const wrapper = createWrapper(client); const options = { loadManifest: async () => manifestJson, loader }; const first = renderHook(() => useBankDashboard(options), { wrapper }); await waitFor(() => expect(first.result.current.status).toBe("chain-mismatch")); await act(async () => { focusManager.setFocused(false); focusManager.setFocused(true); onlineManager.setOnline(false); onlineManager.setOnline(true); await new Promise((resolve) => setTimeout(resolve, 0)); }); first.unmount(); const second = renderHook(() => useBankDashboard(options), { wrapper }); await waitFor(() => expect(second.result.current.status).toBe("chain-mismatch")); expect(loader).toHaveBeenCalledTimes(1); }); it("reconciles exactly once when a new watched block arrives", async () => { const nextSnapshot = { ...snapshot, blockNumber: 13n, synchronizedAt: new Date("2026-08-21T10:01:00.000Z") }; const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot); const { result } = renderHook(() => useBankDashboard({ loadManifest: async () => manifestJson, loader, }), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.status).toBe("ready")); act(() => { notifyBlock?.(13n); notifyBlock?.(13n); }); await waitFor(() => { expect(loader).toHaveBeenCalledTimes(2); expect(result.current.status === "ready" && result.current.snapshot.blockNumber).toBe(13n); }); }); });