diff --git a/frontend/src/hooks/__tests__/useOwnerWeights.test.ts b/frontend/src/hooks/__tests__/useOwnerWeights.test.ts index bce0eea..25f5a66 100644 --- a/frontend/src/hooks/__tests__/useOwnerWeights.test.ts +++ b/frontend/src/hooks/__tests__/useOwnerWeights.test.ts @@ -1,35 +1,122 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as contract from "../../lib/contract"; import { useOwnerWeights } from "../useOwnerWeights"; +vi.mock("../../lib/contract", () => ({ + getOwnerWeights: vi.fn(), +})); + +const intervalMs = 5000; +const initialWeights = [ + { address: "GOWNER111", weight: 4 }, + { address: "GOWNER222", weight: 6 }, +]; +const refreshedWeights = [ + { address: "GOWNER111", weight: 3 }, + { address: "GOWNER222", weight: 7 }, +]; + describe("useOwnerWeights", () => { - test("returns no weights for an empty owner list", () => { - const { result } = renderHook(() => useOwnerWeights([])); + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("fetches owner weights on mount", async () => { + vi.mocked(contract.getOwnerWeights).mockResolvedValueOnce(initialWeights); + + const { result } = renderHook(() => useOwnerWeights(intervalMs)); expect(result.current).toEqual({ - weightsByAddress: {}, - totalWeight: 0, - loading: false, + ownerWeights: [], + loading: true, error: null, }); + + await vi.waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(1); + expect(result.current.ownerWeights).toEqual(initialWeights); + expect(result.current.error).toBeNull(); }); - test("loads current flat voting weights for owners", async () => { - const ownerAddresses = ["GOWNER111", "GOWNER222"]; + test("refreshes owner weights on an interval", async () => { + vi.mocked(contract.getOwnerWeights) + .mockResolvedValueOnce(initialWeights) + .mockResolvedValueOnce(refreshedWeights); + + const { result } = renderHook(() => useOwnerWeights(intervalMs)); - const { result } = renderHook(() => useOwnerWeights(ownerAddresses)); + await vi.waitFor(() => { + expect(result.current.ownerWeights).toEqual(initialWeights); + }); - expect(result.current.loading).toBe(true); + act(() => { + vi.advanceTimersByTime(intervalMs); + }); - await waitFor(() => { - expect(result.current.loading).toBe(false); + await vi.waitFor(() => { + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(2); + expect(result.current.ownerWeights).toEqual(refreshedWeights); }); + expect(result.current.loading).toBe(false); expect(result.current.error).toBeNull(); - expect(result.current.totalWeight).toBe(2); - expect(result.current.weightsByAddress).toEqual({ - GOWNER111: 1, - GOWNER222: 1, + }); + + test("keeps cached owner weights when a refresh fails", async () => { + const refreshError = new Error("RPC unavailable"); + vi.mocked(contract.getOwnerWeights) + .mockResolvedValueOnce(initialWeights) + .mockRejectedValueOnce(refreshError); + + const { result } = renderHook(() => useOwnerWeights(intervalMs)); + + await vi.waitFor(() => { + expect(result.current.ownerWeights).toEqual(initialWeights); + }); + + act(() => { + vi.advanceTimersByTime(intervalMs); }); + + await vi.waitFor(() => { + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(2); + expect(result.current.error).toBe("RPC unavailable"); + }); + + expect(result.current.ownerWeights).toEqual(initialWeights); + expect(result.current.loading).toBe(false); + expect(console.error).toHaveBeenCalledWith( + "Failed to fetch owner weights", + refreshError, + ); + }); + + test("stops refreshing after unmount", async () => { + vi.mocked(contract.getOwnerWeights).mockResolvedValueOnce(initialWeights); + + const { result, unmount } = renderHook(() => useOwnerWeights(intervalMs)); + + await vi.waitFor(() => { + expect(result.current.ownerWeights).toEqual(initialWeights); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(intervalMs); + }); + + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/src/hooks/useOwnerWeights.ts b/frontend/src/hooks/useOwnerWeights.ts index 18c2ccf..33188a6 100644 --- a/frontend/src/hooks/useOwnerWeights.ts +++ b/frontend/src/hooks/useOwnerWeights.ts @@ -73,6 +73,7 @@ export function useOwnerWeights(ownerAddresses: string[]) { return () => { cancelled = true; + clearInterval(intervalId); }; }, [ownerAddresses]); diff --git a/frontend/src/pages/OwnersPage.test.tsx b/frontend/src/pages/OwnersPage.test.tsx index caa5d5c..ffcc02f 100644 --- a/frontend/src/pages/OwnersPage.test.tsx +++ b/frontend/src/pages/OwnersPage.test.tsx @@ -35,18 +35,17 @@ describe("OwnersPage", () => { test("shows weighted quorum and each owner voting share", () => { mockUseOwnerWeights.mockReturnValue({ - weightsByAddress: { - GOWNER111: 5, - GOWNER222: 15, - }, - totalWeight: 20, + ownerWeights: [ + { address: "GOWNER111", weight: 5 }, + { address: "GOWNER222", weight: 15 }, + ], loading: false, error: null, }); renderOwnersPage(); - expect(mockUseOwnerWeights).toHaveBeenCalledWith(ownerAddresses); + expect(mockUseOwnerWeights).toHaveBeenCalledWith(); expect(screen.getByText("Requires 5 of 20 voting weight")).toBeInTheDocument(); expect(screen.getByText("25.0% of voting power must approve.")) .toBeInTheDocument(); @@ -60,8 +59,7 @@ describe("OwnersPage", () => { test("keeps owners visible while voting weights load", () => { mockUseOwnerWeights.mockReturnValue({ - weightsByAddress: {}, - totalWeight: 0, + ownerWeights: [], loading: true, error: null, }); @@ -79,8 +77,7 @@ describe("OwnersPage", () => { test("keeps owners visible when voting weights fail to load", () => { mockUseOwnerWeights.mockReturnValue({ - weightsByAddress: {}, - totalWeight: 0, + ownerWeights: [], loading: false, error: "Failed to load owner weights", }); diff --git a/frontend/src/pages/OwnersPage.tsx b/frontend/src/pages/OwnersPage.tsx index 6468718..5df2936 100644 --- a/frontend/src/pages/OwnersPage.tsx +++ b/frontend/src/pages/OwnersPage.tsx @@ -218,11 +218,14 @@ export function OwnersPage({
{ownerWeightsLoading ? `Loading voting power across ${ownerCountLabel}...` - : ownerWeightsError + : weightsUnavailable ? "Voting power unavailable; owners remain visible." : `${quorumPercent} of voting power must approve.`}
- {ownerWeightsError && ( + {weightsStale && ( +Voting weights may be stale.
+ )} + {weightsUnavailable && (Voting weights unavailable.
)} diff --git a/frontend/src/types/accord.ts b/frontend/src/types/accord.ts index 85168f2..ca6c200 100644 --- a/frontend/src/types/accord.ts +++ b/frontend/src/types/accord.ts @@ -29,6 +29,11 @@ export type Owner = { label: string; }; +export type OwnerWeight = { + address: string; + weight: number; +}; + export type DashboardStat = { label: string; value: string;