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
11 changes: 11 additions & 0 deletions frontend/__tests__/hooks/mockApiClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as api from "@/lib/api";
import type { Bet, Market, PortfolioSummary } from "@/lib/api";

// Re-export Bet so hook tests can import the fixture type from one place.
export type { Bet };

/**
* Shared fixtures and typed handles for the mocked `@/lib/api` client.
* Each hook test file calls `jest.mock("@/lib/api")` itself, since jest.mock is
Expand Down Expand Up @@ -38,6 +41,13 @@ export const BET: Bet = {
payout: null,
};

/** An older bet — used to verify most-recent-first sort in useMarketBets. */
export const OLDER_BET: Bet = {
...BET,
id: "bet-0",
placedAt: "2026-06-10T08:00:00Z",
};

export const SUMMARY: PortfolioSummary = {
totalStaked: "100000000",
totalWinnings: "0",
Expand All @@ -51,6 +61,7 @@ export const SUMMARY: PortfolioSummary = {

export const mockFetchMarkets = api.fetchMarkets as jest.MockedFunction<typeof api.fetchMarkets>;
export const mockFetchMarketById = api.fetchMarketById as jest.MockedFunction<typeof api.fetchMarketById>;
export const mockFetchMarketBets = api.fetchMarketBets as jest.MockedFunction<typeof api.fetchMarketBets>;
export const mockFetchBetsByAddress = api.fetchBetsByAddress as jest.MockedFunction<typeof api.fetchBetsByAddress>;
export const mockFetchPortfolioSummary = api.fetchPortfolioSummary as jest.MockedFunction<typeof api.fetchPortfolioSummary>;

Expand Down
117 changes: 117 additions & 0 deletions frontend/__tests__/hooks/useMarketBets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { useMarketBets } from "@/hooks/useMarketBets";
import { BET, OLDER_BET, apiError, mockFetchMarketBets, pending } from "./mockApiClient";

jest.mock("@/lib/api");

afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});

describe("useMarketBets", () => {
describe("loading state", () => {
it("starts loading with an empty bets list before the request settles", () => {
mockFetchMarketBets.mockReturnValue(pending());

const { result } = renderHook(() => useMarketBets("mkt-1"));

expect(result.current.isLoading).toBe(true);
expect(result.current.bets).toEqual([]);
expect(result.current.error).toBeNull();
});

it("returns to loading while a refetch is in flight", async () => {
mockFetchMarketBets.mockResolvedValueOnce([BET]);
const { result } = renderHook(() => useMarketBets("mkt-1"));
await waitFor(() => expect(result.current.isLoading).toBe(false));

mockFetchMarketBets.mockReturnValue(pending());
act(() => {
result.current.refetch();
});

await waitFor(() => expect(result.current.isLoading).toBe(true));
});
});

describe("success state", () => {
it("exposes fetched bets and clears loading", async () => {
mockFetchMarketBets.mockResolvedValue([BET]);

const { result } = renderHook(() => useMarketBets("mkt-1"));

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.bets).toHaveLength(1);
expect(result.current.error).toBeNull();
});

it("passes the market id to the API client", async () => {
mockFetchMarketBets.mockResolvedValue([BET]);

const { result } = renderHook(() => useMarketBets("mkt-1"));

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockFetchMarketBets).toHaveBeenCalledWith("mkt-1");
});

it("sorts bets most-recent-first", async () => {
// OLDER_BET (2026-06-10) is returned first from the API; BET (2026-06-20) second.
mockFetchMarketBets.mockResolvedValue([OLDER_BET, BET]);

const { result } = renderHook(() => useMarketBets("mkt-1"));

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.bets[0].id).toBe("bet-1"); // newer first
expect(result.current.bets[1].id).toBe("bet-0"); // older second
});

it("refetches on demand", async () => {
mockFetchMarketBets.mockResolvedValue([BET]);
const { result } = renderHook(() => useMarketBets("mkt-1"));
await waitFor(() => expect(result.current.isLoading).toBe(false));

await act(async () => {
result.current.refetch();
});

expect(mockFetchMarketBets).toHaveBeenCalledTimes(2);
});
});

describe("error state", () => {
it("surfaces an API failure as an Error and keeps bets empty", async () => {
mockFetchMarketBets.mockRejectedValue(apiError(500, "Internal Server Error"));

const { result } = renderHook(() => useMarketBets("mkt-1"));

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toContain("500");
expect(result.current.bets).toEqual([]);
});

it("wraps a non-Error rejection", async () => {
mockFetchMarketBets.mockRejectedValue("network down");

const { result } = renderHook(() => useMarketBets("mkt-1"));

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toEqual(new Error("Unknown error"));
});

it("clears a previous error once a retry succeeds", async () => {
mockFetchMarketBets.mockRejectedValueOnce(apiError(500, "Internal Server Error"));
const { result } = renderHook(() => useMarketBets("mkt-1"));
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));

mockFetchMarketBets.mockResolvedValueOnce([BET]);
await act(async () => {
result.current.refetch();
});

expect(result.current.error).toBeNull();
expect(result.current.bets).toHaveLength(1);
});
});
});
198 changes: 187 additions & 11 deletions frontend/components/LoadingSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,207 @@
/**
* LoadingSkeleton — #1113
*
* Skeleton placeholder variants that match the exact dimensions of the real
* content they replace, preventing layout shift while data loads.
*
* Variants:
* "card" – mirrors MarketCard (bg-gray-800 rounded-xl p-4 border border-gray-700)
* "row" – mirrors a PortfolioTable row (6 columns, ~h-[52px] per row)
* "detail" – mirrors MarketDetailClient sections (fighters + bet history table)
* "table" – generic table rows (legacy, kept for back-compat)
* "chart" – full-width chart area (legacy, kept for back-compat)
*/

export type SkeletonVariant = "card" | "row" | "detail" | "table" | "chart";

export interface LoadingSkeletonProps {
variant: "card" | "table" | "chart";
variant: SkeletonVariant;
/** Number of repeated items to render (applies to card / row / table). */
count?: number;
}

export function LoadingSkeleton({ variant, count = 1 }: LoadingSkeletonProps): JSX.Element {
const items = Array.from({ length: count });
// ─── Shared shimmer block ─────────────────────────────────────────────────────

function Shimmer({ className }: { className: string }): JSX.Element {
return <div className={`animate-pulse bg-gray-700 rounded ${className}`} />;
}

// ─── Card variant ─────────────────────────────────────────────────────────────
// Mirrors MarketCard: rounded-xl p-4 border border-gray-700 bg-gray-800
// Inner structure: title row + weight-class/date line + odds bar

function CardSkeleton(): JSX.Element {
return (
<div className="bg-gray-800 rounded-xl p-4 border border-gray-700">
{/* Title row: fighter names + status badge */}
<div className="flex items-start justify-between gap-2 mb-3">
<Shimmer className="h-4 w-3/5" />
<Shimmer className="h-5 w-14 rounded-full" />
</div>
{/* Weight class · date line */}
<Shimmer className="h-3 w-2/5 mb-3" />
{/* Odds bar */}
<Shimmer className="h-4 w-full rounded-full" />
</div>
);
}

// ─── Row variant ──────────────────────────────────────────────────────────────
// Mirrors PortfolioTable row: 6 columns (Fight | Side | Amount | Status | Payout | Action)

function RowSkeleton(): JSX.Element {
return (
<tr className="bg-gray-900 border-b border-gray-700">
<td className="px-4 py-3"><Shimmer className="h-4 w-32" /></td>
<td className="px-4 py-3"><Shimmer className="h-4 w-16" /></td>
<td className="px-4 py-3"><Shimmer className="h-4 w-20" /></td>
<td className="px-4 py-3"><Shimmer className="h-5 w-16 rounded-full" /></td>
<td className="px-4 py-3"><Shimmer className="h-4 w-12" /></td>
<td className="px-4 py-3"><Shimmer className="h-8 w-20 rounded-lg" /></td>
</tr>
);
}

// ─── Detail variant ───────────────────────────────────────────────────────────
// Mirrors MarketDetailClient layout:
// • header (title + badge)
// • countdown bar
// • two fighter cards side-by-side
// • odds bar
// • chart area
// • bet history table header + rows

function DetailSkeleton(): JSX.Element {
return (
<div className="space-y-5">
{/* Header: title + status badge */}
<div className="flex flex-wrap items-center gap-3">
<Shimmer className="h-7 w-64" />
<Shimmer className="h-6 w-20 rounded-full" />
</div>

{/* Countdown bar */}
<Shimmer className="h-6 w-48" />

{/* Fighter cards */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 bg-gray-800 rounded-xl p-4 border border-gray-700 space-y-2">
<Shimmer className="h-5 w-32" />
<Shimmer className="h-4 w-24" />
<Shimmer className="h-4 w-20" />
<Shimmer className="h-6 w-full rounded-full mt-2" />
</div>
<div className="flex-1 bg-gray-800 rounded-xl p-4 border border-gray-700 space-y-2">
<Shimmer className="h-5 w-32" />
<Shimmer className="h-4 w-24" />
<Shimmer className="h-4 w-20" />
<Shimmer className="h-6 w-full rounded-full mt-2" />
</div>
</div>

{/* Odds bar */}
<Shimmer className="h-4 w-full rounded-full" />

{/* Chart area */}
<Shimmer className="w-full h-48 rounded-xl" />

{/* Bet history table */}
<div className="bg-gray-800 rounded-xl p-4">
<Shimmer className="h-4 w-36 mb-3" />
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700">
{["w-24", "w-16", "w-20", "w-24"].map((w, i) => (
<th key={i} className="pb-2 pr-4 text-left">
<Shimmer className={`h-3 ${w}`} />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b border-gray-700">
<td className="py-2 pr-4"><Shimmer className="h-4 w-24" /></td>
<td className="py-2 pr-4"><Shimmer className="h-4 w-16" /></td>
<td className="py-2 pr-4"><Shimmer className="h-4 w-20" /></td>
<td className="py-2"><Shimmer className="h-4 w-24" /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

// ─── Table variant (legacy) ───────────────────────────────────────────────────

function TableSkeleton({ count }: { count: number }): JSX.Element {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-gray-800 rounded h-10" />
))}
</div>
);
}

// ─── Chart variant (legacy) ───────────────────────────────────────────────────

function ChartSkeleton(): JSX.Element {
return <div className="bg-gray-800 rounded-xl h-48 animate-pulse w-full" />;
}

// ─── Public component ─────────────────────────────────────────────────────────

export function LoadingSkeleton({ variant, count = 1 }: LoadingSkeletonProps): JSX.Element {
if (variant === "card") {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((_, i) => (
<div key={i} className="bg-gray-800 rounded-xl p-4 animate-pulse h-40" />
{Array.from({ length: count }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}

if (variant === "table") {
if (variant === "row") {
return (
<div className="space-y-2 animate-pulse">
{items.map((_, i) => (
<div key={i} className="bg-gray-800 rounded h-10" />
))}
<div className="overflow-x-auto rounded-xl border border-gray-700">
<table className="min-w-full text-sm text-left">
{/* Column headers mirror PortfolioTable */}
<thead className="bg-gray-800">
<tr>
{["Fight", "Side", "Amount", "Status", "Payout", "Action"].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{Array.from({ length: count }).map((_, i) => (
<RowSkeleton key={i} />
))}
</tbody>
</table>
</div>
);
}

if (variant === "detail") {
return <DetailSkeleton />;
}

if (variant === "table") {
return <TableSkeleton count={count} />;
}

// chart
return <div className="bg-gray-800 rounded-xl h-48 animate-pulse w-full" />;
return <ChartSkeleton />;
}
Loading
Loading