fix: stabilize terminal dashboard states
This commit is contained in:
@@ -100,6 +100,19 @@ describe("read-only operations console", () => {
|
|||||||
expect(screen.getByText("500.00 mUSDC")).toBeTruthy();
|
expect(screen.getByText("500.00 mUSDC")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes every learning abbreviation keyboard-focusable with its explanation intact", () => {
|
||||||
|
const { container } = render(<App dashboard={ready()} />);
|
||||||
|
const abbreviations = Array.from(container.querySelectorAll("abbr"));
|
||||||
|
expect(abbreviations).toHaveLength(5);
|
||||||
|
|
||||||
|
for (const abbreviation of abbreviations) {
|
||||||
|
expect(abbreviation.getAttribute("title")?.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(abbreviation.getAttribute("tabindex")).toBe("0");
|
||||||
|
(abbreviation as HTMLElement).focus();
|
||||||
|
expect(document.activeElement).toBe(abbreviation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("renders valid V1 activity newest first beside an isolated decode warning", () => {
|
it("renders valid V1 activity newest first beside an isolated decode warning", () => {
|
||||||
render(<App dashboard={ready()} />);
|
render(<App dashboard={ready()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export function AccountingGrid({ snapshot }: { snapshot: DashboardSnapshot }) {
|
|||||||
<p className="helper">Solvent when reserves are greater than or equal to liabilities.</p>
|
<p className="helper">Solvent when reserves are greater than or equal to liabilities.</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="accounting-grid">
|
<dl className="accounting-grid">
|
||||||
<div className="hero-figure"><dt><abbr title="MockUSDC held by the stable proxy address">Reserves</abbr></dt><dd>{formatAmount(snapshot.reserves)}</dd></div>
|
<div className="hero-figure"><dt><abbr title="MockUSDC held by the stable proxy address" tabIndex={0}>Reserves</abbr></dt><dd>{formatAmount(snapshot.reserves)}</dd></div>
|
||||||
<div className="hero-figure"><dt><abbr title="The sum of all balances recorded in the bank ledger">Liabilities</abbr></dt><dd>{formatAmount(snapshot.liabilities)}</dd></div>
|
<div className="hero-figure"><dt><abbr title="The sum of all balances recorded in the bank ledger" tabIndex={0}>Liabilities</abbr></dt><dd>{formatAmount(snapshot.liabilities)}</dd></div>
|
||||||
<div><dt>Surplus</dt><dd>{formatSurplus(snapshot.surplus)}</dd></div>
|
<div><dt>Surplus</dt><dd>{formatSurplus(snapshot.surplus)}</dd></div>
|
||||||
<div><dt>Reserve ratio</dt><dd>{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}</dd></div>
|
<div><dt>Reserve ratio</dt><dd>{formatReserveRatio(snapshot.reserves, snapshot.liabilities)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ export function ContractIdentity({ manifest, snapshot }: { manifest: DeploymentM
|
|||||||
<p className="helper">Reads target the proxy. The implementation address identifies its current logic.</p>
|
<p className="helper">Reads target the proxy. The implementation address identifies its current logic.</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="identity-list">
|
<dl className="identity-list">
|
||||||
<div className="proxy-row"><dt><abbr title="The stable application address that holds storage and reserves">Proxy</abbr></dt><AddressValue label="Proxy" address={snapshot.proxy} manifest={manifest} primary /></div>
|
<div className="proxy-row"><dt><abbr title="The stable application address that holds storage and reserves" tabIndex={0}>Proxy</abbr></dt><AddressValue label="Proxy" address={snapshot.proxy} manifest={manifest} primary /></div>
|
||||||
<div><dt><abbr title="The replaceable contract containing the current executable logic">Implementation</abbr></dt><AddressValue label="Implementation" address={snapshot.implementation} manifest={manifest} /></div>
|
<div><dt><abbr title="The replaceable contract containing the current executable logic" tabIndex={0}>Implementation</abbr></dt><AddressValue label="Implementation" address={snapshot.implementation} manifest={manifest} /></div>
|
||||||
<div><dt>Token</dt><AddressValue label="Token" address={snapshot.asset} manifest={manifest} /></div>
|
<div><dt>Token</dt><AddressValue label="Token" address={snapshot.asset} manifest={manifest} /></div>
|
||||||
<div><dt><abbr title="The account authorized to pause and upgrade the proxy">Owner</abbr></dt><AddressValue label="Owner" address={snapshot.owner} manifest={manifest} /></div>
|
<div><dt><abbr title="The account authorized to pause and upgrade the proxy" tabIndex={0}>Owner</abbr></dt><AddressValue label="Owner" address={snapshot.owner} manifest={manifest} /></div>
|
||||||
<div><dt>Deployment block</dt><dd>{manifest.deploymentBlock.toString()}</dd></div>
|
<div><dt>Deployment block</dt><dd>{manifest.deploymentBlock.toString()}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { focusManager, onlineManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Address } from "viem";
|
import type { Address } from "viem";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
|
import type { DashboardSnapshot, DeploymentManifest } from "../types/dashboard";
|
||||||
import { useBankDashboard } from "./useBankDashboard";
|
import { useBankDashboard } from "./useBankDashboard";
|
||||||
|
|
||||||
@@ -55,8 +55,7 @@ function deferred<T>() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper(client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })) {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
};
|
};
|
||||||
@@ -65,6 +64,13 @@ function createWrapper() {
|
|||||||
describe("useBankDashboard", () => {
|
describe("useBankDashboard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
notifyBlock = undefined;
|
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", () => {
|
it("starts in loading while the manifest and first reconciled snapshot are pending", () => {
|
||||||
@@ -151,6 +157,52 @@ describe("useBankDashboard", () => {
|
|||||||
expect(loader).toHaveBeenCalledTimes(1);
|
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 () => {
|
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 nextSnapshot = { ...snapshot, blockNumber: 13n, synchronizedAt: new Date("2026-08-21T10:01:00.000Z") };
|
||||||
const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot);
|
const loader = vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(nextSnapshot);
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS
|
|||||||
queryKey: manifestQueryKey,
|
queryKey: manifestQueryKey,
|
||||||
queryFn: loadManifest,
|
queryFn: loadManifest,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
retryOnMount: false,
|
||||||
staleTime: Number.POSITIVE_INFINITY,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedManifest = useMemo(() => {
|
const parsedManifest = useMemo(() => {
|
||||||
@@ -79,6 +83,10 @@ export function useBankDashboard(options: BankDashboardOptions = {}): DashboardS
|
|||||||
},
|
},
|
||||||
enabled: manifest !== undefined,
|
enabled: manifest !== undefined,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
retryOnMount: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
const mismatch = snapshotQuery.error !== null && isChainMismatch(snapshotQuery.error);
|
const mismatch = snapshotQuery.error !== null && isChainMismatch(snapshotQuery.error);
|
||||||
const latestSnapshot = useRef<DashboardSnapshot | undefined>(undefined);
|
const latestSnapshot = useRef<DashboardSnapshot | undefined>(undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user