diff --git a/next.config.ts b/next.config.ts index 138669f..4e3abba 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,8 +42,6 @@ const nextConfig: NextConfig = { unoptimized: true, }, ...(isStaticExport ? { output: "export" } : {}), - output: "export", - images: { unoptimized: true }, devIndicators: false, ...(basePath ? { basePath, assetPrefix: basePath } : {}), }; diff --git a/playwright.config.ts b/playwright.config.ts index a66a02d..962d51b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,5 +21,13 @@ export default defineConfig({ command: "npm run dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, + env: { + // Routed to a same-origin path so table-accessibility.spec.ts can + // serve deterministic leaderboard data via page.route() instead of + // mocking the Soroban RPC/event-scan path the app falls back to + // when this is unset. + NEXT_PUBLIC_LEADERBOARD_API_URL: + "http://localhost:3000/__mock-leaderboard-api", + }, }, }); diff --git a/src/app/history/page.test.tsx b/src/app/history/page.test.tsx new file mode 100644 index 0000000..451f9a8 --- /dev/null +++ b/src/app/history/page.test.tsx @@ -0,0 +1,131 @@ +import { render, screen, act } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useStellarWallet } from "@/context/StellarWalletContext"; +import { ErrorProvider } from "@/context/ErrorContext"; +import HistoryPage from "./page"; + +vi.mock("@/context/StellarWalletContext", () => ({ + useStellarWallet: vi.fn(), +})); + +vi.mock("@/config", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, poolContractId: "CPOOLCONTRACTID" }; +}); + +vi.mock("@/lib/soroban", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getUserTransactionHistory: vi.fn() }; +}); + +const { getUserTransactionHistory } = await import("@/lib/soroban"); +const getUserTransactionHistoryMock = vi.mocked(getUserTransactionHistory); +const useStellarWalletMock = vi.mocked(useStellarWallet); + +const connectedWallet = { + publicKey: "GA3CD2PYXOQCXW7ZVQW3MOA3JFZCE4F4IG2FD66I55TQASPCNKYYEFRN", + walletApi: null, + networkName: "TESTNET", + isNetworkMismatch: false, + isConnected: true, + connect: vi.fn(), + disconnect: vi.fn(), +}; + +function historyEntry(i: number) { + return { + date: new Date(2024, 0, i + 1).toISOString(), + action: (i % 2 === 0 ? "lock" : "unlock") as "lock" | "unlock", + amount: "10000000", + symbol: "XLM", + poolId: "pool-1", + creditsEarned: "5", + txHash: `hash-${i}`, + }; +} + +function renderPage() { + return render( + + + + + , + ); +} + +function liveRegionText() { + return screen.getByRole("status").textContent; +} + +beforeEach(() => { + getUserTransactionHistoryMock.mockReset(); + useStellarWalletMock.mockReturnValue(connectedWallet); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("HistoryPage accessible live-refresh announcements (#86)", () => { + it("does not announce anything while disconnected", async () => { + useStellarWalletMock.mockReturnValue({ + ...connectedWallet, + publicKey: null, + isConnected: false, + }); + + await act(async () => { + renderPage(); + }); + + expect(liveRegionText()).toBe(""); + }); + + it("announces the loaded range once history resolves", async () => { + getUserTransactionHistoryMock.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => historyEntry(i)), + ); + + await act(async () => { + renderPage(); + }); + + expect(liveRegionText()).toBe( + "History updated, showing 1-5 of 5 transactions.", + ); + }); + + it("announces 'No farming history found' rather than staying silent on zero entries", async () => { + getUserTransactionHistoryMock.mockResolvedValue([]); + + await act(async () => { + renderPage(); + }); + + expect(liveRegionText()).toBe("No farming history found."); + }); + + it("computes the correct upper bound on a partial final page", async () => { + // PAGE_SIZE is 20; 45 entries means the last page holds 5, not 20. + getUserTransactionHistoryMock.mockResolvedValue( + Array.from({ length: 45 }, (_, i) => historyEntry(i)), + ); + + await act(async () => { + renderPage(); + }); + expect(liveRegionText()).toBe( + "History updated, showing 1-20 of 45 transactions.", + ); + + await act(async () => { + screen.getByRole("button", { name: "3" }).click(); + }); + + expect(liveRegionText()).toBe( + "History updated, showing 41-45 of 45 transactions.", + ); + }); +}); diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 1949359..4abb65c 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -20,6 +20,8 @@ import { } from "@chakra-ui/react"; import { useStellarWallet } from "@/context/StellarWalletContext"; import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletButton"; +import LiveRegion from "@/components/LiveRegion/LiveRegion"; +import { useLiveAnnouncer } from "@/hooks/useLiveAnnouncer"; import { getUserTransactionHistory, stellarExpertTxUrl, @@ -88,8 +90,19 @@ export default function HistoryPage() { const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE)); const paged = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const rangeStart = entries.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1; + const rangeEnd = Math.min(page * PAGE_SIZE, entries.length); + const announcementMessage = + !isConnected || isLoading + ? "" + : entries.length === 0 + ? "No farming history found." + : `History updated, showing ${rangeStart}-${rangeEnd} of ${entries.length.toLocaleString()} transactions.`; + const announcement = useLiveAnnouncer(announcementMessage); + return ( + diff --git a/src/app/leaderboard/page.test.tsx b/src/app/leaderboard/page.test.tsx new file mode 100644 index 0000000..0c5a013 --- /dev/null +++ b/src/app/leaderboard/page.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useStellarWallet } from "@/context/StellarWalletContext"; +import LeaderboardPage from "./page"; + +vi.mock("@/context/StellarWalletContext", () => ({ + useStellarWallet: vi.fn(), +})); + +vi.mock("@/lib/soroban", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sorobanService: { ...actual.sorobanService, getLeaderboard: vi.fn() }, + }; +}); + +const { sorobanService } = await import("@/lib/soroban"); +const getLeaderboardMock = vi.mocked(sorobanService.getLeaderboard); +const useStellarWalletMock = vi.mocked(useStellarWallet); + +function entry(i: number) { + return { + address: `GADDRESS${i}${"X".repeat(48)}`.slice(0, 56), + totalCredits: 1000 - i, + totalStake: 500 - i, + boostUtilization: 10, + }; +} + +function renderPage() { + return render( + + + , + ); +} + +function liveRegionText() { + return screen.getByRole("status").textContent; +} + +beforeEach(() => { + getLeaderboardMock.mockReset(); + useStellarWalletMock.mockReturnValue({ + publicKey: null, + walletApi: null, + networkName: "TESTNET", + isNetworkMismatch: false, + isConnected: false, + connect: vi.fn(), + disconnect: vi.fn(), + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("LeaderboardPage accessible live-refresh announcements (#86)", () => { + it("announces the initial load with the correct rank range", async () => { + getLeaderboardMock.mockResolvedValue({ + entries: Array.from({ length: 10 }, (_, i) => entry(i)), + total: 42, + }); + + await act(async () => { + renderPage(); + }); + + expect(liveRegionText()).toBe( + "Leaderboard updated, sorted by Credits, showing rank 1-10 of 42.", + ); + }); + + it("re-announces on the 30s auto-refresh when the data actually changes", async () => { + vi.useFakeTimers(); + getLeaderboardMock + .mockResolvedValueOnce({ + entries: Array.from({ length: 10 }, (_, i) => entry(i)), + total: 42, + }) + .mockResolvedValueOnce({ + entries: Array.from({ length: 10 }, (_, i) => entry(i)), + total: 57, + }); + + await act(async () => { + renderPage(); + }); + expect(liveRegionText()).toContain("of 42."); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + + expect(liveRegionText()).toContain("of 57."); + expect(getLeaderboardMock).toHaveBeenCalledTimes(2); + }); + + it("announces on a manual Refresh click, not only the auto-refresh timer", async () => { + getLeaderboardMock + .mockResolvedValueOnce({ + entries: Array.from({ length: 10 }, (_, i) => entry(i)), + total: 42, + }) + .mockResolvedValueOnce({ + entries: Array.from({ length: 10 }, (_, i) => entry(i)), + total: 99, + }); + + await act(async () => { + renderPage(); + }); + expect(liveRegionText()).toContain("of 42."); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + }); + + expect(liveRegionText()).toContain("of 99."); + }); + + it("announces 'No results found' for a search with zero matches, not silence", async () => { + vi.useFakeTimers(); + getLeaderboardMock.mockResolvedValue({ + entries: [entry(0)], + total: 1, + }); + + await act(async () => { + renderPage(); + }); + expect(liveRegionText()).toContain("of 1."); + + fireEvent.change(screen.getByPlaceholderText(/search address/i), { + target: { value: "no-such-address" }, + }); + + // Search is debounced (SEARCH_DEBOUNCE_MS = 300ms) — the announcement + // must not flip until the debounce settles, not on every keystroke. + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(liveRegionText()).toBe("No results found."); + }); + + it("computes the correct upper bound on a partial final page", async () => { + getLeaderboardMock.mockResolvedValue({ + entries: Array.from({ length: 3 }, (_, i) => entry(i)), + total: 3, + }); + + await act(async () => { + renderPage(); + }); + + expect(liveRegionText()).toBe( + "Leaderboard updated, sorted by Credits, showing rank 1-3 of 3.", + ); + }); + + it("keeps aria-sort in sync with the active sort column on both the Select and Th", async () => { + getLeaderboardMock.mockResolvedValue({ + entries: Array.from({ length: 5 }, (_, i) => entry(i)), + total: 5, + }); + + await act(async () => { + renderPage(); + }); + + const creditsHeader = screen.getByRole("columnheader", { name: /credits/i }); + const stakeHeader = screen.getByRole("columnheader", { name: /stake/i }); + expect(creditsHeader.getAttribute("aria-sort")).toBe("descending"); + expect(stakeHeader.getAttribute("aria-sort")).toBe("none"); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /stake/i })); + }); + + expect(creditsHeader.getAttribute("aria-sort")).toBe("none"); + expect(stakeHeader.getAttribute("aria-sort")).toBe("descending"); + expect(liveRegionText()).toContain("sorted by Stake"); + }); +}); diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index d4b3879..5497c47 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -19,6 +19,15 @@ import { } from "@chakra-ui/react"; import { useStellarWallet } from "@/context/StellarWalletContext"; import { useLeaderboard, PAGE_SIZE, type SortKey } from "@/hooks/useLeaderboard"; +import { useLiveAnnouncer } from "@/hooks/useLiveAnnouncer"; +import LiveRegion from "@/components/LiveRegion/LiveRegion"; + +const LEADERBOARD_TABLE_ID = "leaderboard-table"; + +const SORT_LABELS: Record = { + credits: "Credits", + stake: "Stake", +}; function truncate(addr: string): string { if (addr.length <= 12) return addr; @@ -43,8 +52,21 @@ export default function LeaderboardPage() { refresh, } = useLeaderboard(publicKey); + // Matches the spinner-vs-table branch below: nothing to announce yet + // while the very first fetch is still in flight. + const isInitialLoad = isLoading && paged.length === 0; + const rangeStart = filteredCount === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1; + const rangeEnd = Math.min(currentPage * PAGE_SIZE, filteredCount); + const announcementMessage = isInitialLoad + ? "" + : filteredCount === 0 + ? "No results found." + : `Leaderboard updated, sorted by ${SORT_LABELS[sortKey]}, showing rank ${rangeStart}-${rangeEnd} of ${filteredCount.toLocaleString()}.`; + const announcement = useLiveAnnouncer(announcementMessage); + return ( + @@ -100,6 +122,8 @@ export default function LeaderboardPage() {