Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ const nextConfig: NextConfig = {
unoptimized: true,
},
...(isStaticExport ? { output: "export" } : {}),
output: "export",
images: { unoptimized: true },
devIndicators: false,
...(basePath ? { basePath, assetPrefix: basePath } : {}),
};
Expand Down
8 changes: 8 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
});
131 changes: 131 additions & 0 deletions src/app/history/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/config")>();
return { ...actual, poolContractId: "CPOOLCONTRACTID" };
});

vi.mock("@/lib/soroban", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/soroban")>();
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(
<ChakraProvider>
<ErrorProvider>
<HistoryPage />
</ErrorProvider>
</ChakraProvider>,
);
}

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.",
);
});
});
13 changes: 13 additions & 0 deletions src/app/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Flex direction="column" align="center" px={{ base: 6, md: 16 }} py={10} gap={2}>
<LiveRegion message={announcement} />
<Box w="100%" maxW="1000px" mb={4}>
<HStack spacing={2} mb={5}>
<Box w="6px" h="6px" borderRadius="full" bg="app.accent" boxShadow="0 0 8px var(--chakra-colors-app-accent)" />
Expand Down
188 changes: 188 additions & 0 deletions src/app/leaderboard/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/soroban")>();
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(
<ChakraProvider>
<LeaderboardPage />
</ChakraProvider>,
);
}

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");
});
});
Loading
Loading