diff --git a/src/context/SorokitProvider.test.tsx b/src/context/SorokitProvider.test.tsx index 629888c..4706708 100644 --- a/src/context/SorokitProvider.test.tsx +++ b/src/context/SorokitProvider.test.tsx @@ -9,6 +9,8 @@ import { SorokitProvider } from "./SorokitProvider"; import { useSorokit } from "./useSorokit"; const TestComponent = () => { + const { address, account, balances, error, connectWallet, disconnectWallet, switchNetwork } = useSorokit(); + const { address, account, balances, connectWallet, disconnectWallet, switchNetwork, refreshAccount, isLoadingAccount, error, errorHistory } = useSorokit(); return ( @@ -16,6 +18,7 @@ const TestComponent = () => {
{address || "none"}
{account ? account.sequence : "none"}
{balances.length}
+
{error || "none"}
{isLoadingAccount ? "true" : "false"}
{error || "none"}
{errorHistory.length}
@@ -83,7 +86,10 @@ describe("SorokitProvider", () => { } as unknown as ReturnType; }); - it("disconnectWallet clears address, account, and balances", async () => { + it("disconnectWallet clears address, account, balances, and error", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account error" }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances error" }); + renderWithProvider(, { client: mockClient }); const connectBtn = screen.getByText("Connect"); @@ -96,8 +102,7 @@ describe("SorokitProvider", () => { expect(screen.getByTestId("address")).toHaveTextContent("GABC"); await waitFor(() => { - expect(screen.getByTestId("account")).toHaveTextContent("100"); - expect(screen.getByTestId("balances")).toHaveTextContent("1"); + expect(screen.getByTestId("error")).toHaveTextContent("Account error; Balances error"); }); await act(async () => { @@ -107,6 +112,84 @@ describe("SorokitProvider", () => { expect(screen.getByTestId("address")).toHaveTextContent("none"); expect(screen.getByTestId("account")).toHaveTextContent("none"); expect(screen.getByTestId("balances")).toHaveTextContent("0"); + expect(screen.getByTestId("error")).toHaveTextContent("none"); + }); + + it("combines error strings when both getAccount and getBalances fail", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account failed" }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Balances failed" }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Account failed; Balances failed"); + }); + }); + + it("shows single error when only getAccount fails", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: null, error: "Account not found" }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: [{ asset: "XLM", balance: "10" }], error: null }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Account not found"); + expect(screen.getByTestId("balances")).toHaveTextContent("1"); + }); + }); + + it("shows single error when only getBalances fails", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: { sequence: "100" }, error: null }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: null, error: "Failed to fetch balances" }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Failed to fetch balances"); + expect(screen.getByTestId("account")).toHaveTextContent("100"); + }); + }); + + it("clears error from previous session on reconnect", async () => { + mockClient.account.getAccount = vi.fn().mockResolvedValueOnce({ data: null, error: "Old error" }); + mockClient.account.getBalances = vi.fn().mockResolvedValueOnce({ data: [], error: null }); + + renderWithProvider(, { client: mockClient }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Old error"); + }); + + // Next connect with different address succeeds + mockClient.wallet.connect = vi.fn().mockResolvedValue({ data: { address: "GDEF" }, error: null }); + mockClient.account.getAccount = vi.fn().mockResolvedValue({ data: { sequence: "200" }, error: null }); + mockClient.account.getBalances = vi.fn().mockResolvedValue({ data: [{ asset: "XLM", balance: "50" }], error: null }); + + await act(async () => { + fireEvent.click(screen.getByText("Connect")); + }); + + await waitFor(() => { + expect(screen.getByTestId("address")).toHaveTextContent("GDEF"); + expect(screen.getByTestId("error")).toHaveTextContent("none"); + expect(screen.getByTestId("account")).toHaveTextContent("200"); + }); }); it("connectWallet populates address on success", async () => { diff --git a/src/context/SorokitProvider.tsx b/src/context/SorokitProvider.tsx index ce78c5b..3777315 100644 --- a/src/context/SorokitProvider.tsx +++ b/src/context/SorokitProvider.tsx @@ -149,7 +149,12 @@ export function SorokitProvider({ // Load account when address changes useEffect(() => { - if (!address) return; + setError(null); + if (!address) { + setAccount(null); + setBalances([]); + return; + } let active = true; const timerId = window.setTimeout(() => { @@ -162,6 +167,10 @@ export function SorokitProvider({ if (!active) return; if (accountRes.data) setAccount(accountRes.data); if (balancesRes.data) setBalances(balancesRes.data); + const combined = [accountRes.error, balancesRes.error] + .filter(Boolean) + .join("; "); + if (combined) setError(combined); if (accountRes.error && balancesRes.error) { reportError( `${accountRes.error}; ${balancesRes.error}`, @@ -227,6 +236,12 @@ export function SorokitProvider({ }, [reportError]); const disconnectWallet = useCallback(async () => { + await client.wallet.disconnect(); + setAddress(null); + setAccount(null); + setBalances([]); + setError(null); + }, [client]); setIsDisconnecting(true); try { // A wallet adapter that throws (e.g. the extension went away diff --git a/src/screens/Dashboard.test.tsx b/src/screens/Dashboard.test.tsx index de37900..94e0e40 100644 --- a/src/screens/Dashboard.test.tsx +++ b/src/screens/Dashboard.test.tsx @@ -1,3 +1,72 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { Dashboard } from "./Dashboard"; + +vi.mock("@/context/useSorokit", () => ({ + useSorokit: vi.fn(() => ({ + isConnected: true, + address: "GABC", + account: null, + balances: [], + network: { name: "testnet" }, + error: null, + clearError: vi.fn(), + })), +})); + +vi.mock("@/screens/WalletScreen", () => ({ + WalletScreen: () =>
WalletScreen
, +})); + +vi.mock("@/screens/AccountScreen", () => ({ + AccountScreen: () =>
AccountScreen
, +})); + +vi.mock("@/screens/TransactionsScreen", () => ({ + TransactionsScreen: () =>
TransactionsScreen
, +})); + +vi.mock("@/screens/SorobanScreen", () => ({ + SorobanScreen: () =>
SorobanScreen
, +})); + +vi.mock("@/screens/NetworkScreen", () => ({ + NetworkScreen: () =>
NetworkScreen
, +})); + +describe("Dashboard screen mounting", () => { + it("mounts only the default active screen (wallet) on load", () => { + render(); + + expect(screen.getByTestId("wallet-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("account-screen")).not.toBeInTheDocument(); + expect(screen.queryByTestId("transactions-screen")).not.toBeInTheDocument(); + expect(screen.queryByTestId("soroban-screen")).not.toBeInTheDocument(); + expect(screen.queryByTestId("network-screen")).not.toBeInTheDocument(); + }); + + it("unmounts previous screen and mounts only the new active screen when navigating", () => { + render(); + + // Click Account in sidebar + fireEvent.click(screen.getByRole("button", { name: /account/i })); + expect(screen.getByTestId("account-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("wallet-screen")).not.toBeInTheDocument(); + + // Click Transactions + fireEvent.click(screen.getByRole("button", { name: /transactions/i })); + expect(screen.getByTestId("transactions-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("account-screen")).not.toBeInTheDocument(); + + // Click Soroban + fireEvent.click(screen.getByRole("button", { name: /soroban/i })); + expect(screen.getByTestId("soroban-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("transactions-screen")).not.toBeInTheDocument(); + + // Click Network + fireEvent.click(screen.getByRole("button", { name: /network/i })); + expect(screen.getByTestId("network-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("soroban-screen")).not.toBeInTheDocument(); import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/src/screens/Dashboard.tsx b/src/screens/Dashboard.tsx index 84e017b..44c1a75 100644 --- a/src/screens/Dashboard.tsx +++ b/src/screens/Dashboard.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; +import { Sidebar, type NavSection } from "@/components/Sidebar"; +import { TopBar } from "@/components/TopBar"; import { type ComponentType, lazy, Suspense, useCallback, useEffect, useState } from "react"; import { ErrorBoundary } from "@/components/ErrorBoundary"; @@ -51,6 +54,10 @@ const PAGE_TITLES: Record = { nfts: "NFTs — Sorokit", }; +export function Dashboard() { + const [active, setActive] = useState("wallet"); + const [sidebarOpen, setSidebarOpen] = useState(false); + const SCREENS: Record = { wallet: WalletScreen, account: AccountScreen, @@ -177,6 +184,12 @@ export function Dashboard({ />
+
+ {active === "wallet" && } + {active === "account" && } + {active === "transactions" && } + {active === "soroban" && } + {active === "network" && }