diff --git a/frontend/components/ClaimButton.resolution.test.tsx b/frontend/components/ClaimButton.resolution.test.tsx
deleted file mode 100644
index 807ab8d5..00000000
--- a/frontend/components/ClaimButton.resolution.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from "react";
-import { render, screen } from "@testing-library/react";
-import { ClaimButton } from "./ClaimButton";
-import { Bet, Market, MarketStatus, Outcome } from "@/lib/api";
-
-jest.mock("@/hooks/useClaimWinnings", () => ({
- useClaimWinnings: () => ({ claim: jest.fn() }),
-}));
-jest.mock("@/hooks/useClaimRefund", () => ({
- useClaimRefund: () => ({ claimRefund: jest.fn() }),
-}));
-
-const RESOLVED_AT = "2026-10-01T12:00:00Z";
-const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime();
-const DAY_MS = 24 * 60 * 60 * 1000;
-
-function makeMarket(status: MarketStatus, outcome: Outcome | null): Market {
- return {
- id: "m-1",
- contractAddress: "C1",
- fighterA: { name: "Maya Chen", record: "10-0", nationality: "US", weightClass: "Welter" },
- fighterB: { name: "Rico Alvarez", record: "9-1", nationality: "MX", weightClass: "Welter" },
- scheduledAt: "2026-09-30T00:00:00Z",
- bettingEndsAt: "2026-09-29T23:00:00Z",
- status,
- outcome,
- poolA: "0",
- poolB: "0",
- totalPool: "0",
- oracleAddress: "G1",
- createdBy: "G2",
- resolvedAt: RESOLVED_AT,
- };
-}
-
-const winningBet: Bet = {
- id: "b-1",
- marketId: "m-1",
- bettor: "GBETTOR",
- side: "FighterA",
- amount: "100",
- placedAt: "2026-09-29T00:00:00Z",
- claimed: false,
- payout: null,
-};
-
-describe("ClaimButton", () => {
- it("renders nothing for a losing bet on a resolved market", () => {
- const { container } = render(
-
- );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("shows a disabled claim with the reason during the dispute window", () => {
- jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000);
- render();
-
- expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
- expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument();
- jest.restoreAllMocks();
- });
-
- it("shows a disabled claim with the reason while the result is disputed", () => {
- render();
-
- expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
- expect(screen.getByText(/decided by an admin/)).toBeInTheDocument();
- });
-
- it("enables the claim once the dispute window has passed", () => {
- jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1);
- render();
-
- expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled();
- expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument();
- jest.restoreAllMocks();
- });
-
- it("allows refunds on a cancelled market straight away", () => {
- render();
-
- expect(screen.getByRole("button", { name: "Claim Refund" })).toBeEnabled();
- });
-
- it("shows a claimed bet as claimed", () => {
- render(
-
- );
-
- expect(screen.getByRole("button", { name: "Claimed" })).toBeDisabled();
- });
-});
diff --git a/frontend/components/ClaimButton.test.tsx b/frontend/components/ClaimButton.test.tsx
index d757f4e9..6bb45edf 100644
--- a/frontend/components/ClaimButton.test.tsx
+++ b/frontend/components/ClaimButton.test.tsx
@@ -13,7 +13,7 @@ const mockClaimRefund = jest.fn();
const fighter = (name: string) => ({ name, record: "10-0", nationality: "USA", weightClass: "Heavyweight" });
-const makeMarket = (overrides: Partial<{ status: string; outcome: string | null }> = {}) =>
+const makeMarket = (overrides: Partial<{ status: string; outcome: string | null; resolvedAt: string | null }> = {}) =>
({
id: "market-1",
contractAddress: "market-1",
@@ -28,6 +28,7 @@ const makeMarket = (overrides: Partial<{ status: string; outcome: string | null
totalPool: "0",
oracleAddress: "GORACLE",
createdBy: "GCREATOR",
+ resolvedAt: null as string | null,
...overrides,
}) as never;
@@ -136,4 +137,53 @@ describe("ClaimButton", () => {
await waitFor(() => expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled());
expect(onClaimed).not.toHaveBeenCalled();
});
+
+ // ── Dispute window blocking (merged from resolution test) ────────────────
+
+ const RESOLVED_AT = "2026-10-01T12:00:00Z";
+ const RESOLVED_AT_MS = new Date(RESOLVED_AT).getTime();
+ const DAY_MS = 24 * 60 * 60 * 1000;
+
+ it("shows a disabled claim with the reason during the dispute window", () => {
+ jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + 1000);
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
+ expect(screen.getByText("Claimable after the dispute window closes.")).toBeInTheDocument();
+ jest.restoreAllMocks();
+ });
+
+ it("shows a disabled claim with the reason while the result is disputed", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeDisabled();
+ expect(screen.getByText(/decided by an admin/)).toBeInTheDocument();
+ });
+
+ it("enables the claim once the dispute window has passed", () => {
+ jest.spyOn(Date, "now").mockReturnValue(RESOLVED_AT_MS + DAY_MS + 1);
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Claim Winnings" })).toBeEnabled();
+ expect(screen.queryByText(/dispute window/)).not.toBeInTheDocument();
+ jest.restoreAllMocks();
+ });
});
diff --git a/frontend/components/DisputeModal.f105-f108.test.tsx b/frontend/components/DisputeModal.f105-f108.test.tsx
deleted file mode 100644
index 42629319..00000000
--- a/frontend/components/DisputeModal.f105-f108.test.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import React from "react";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { DisputeModal } from "./DisputeModal";
-import { Bet, Market } from "@/lib/api";
-import * as stellar from "@/lib/stellar";
-
-jest.mock("@/hooks/useWallet", () => ({
- useWallet: () => ({
- address: "GDISPUTER",
- isNetworkMismatched: false,
- signTransaction: jest.fn().mockResolvedValue("signed-xdr"),
- }),
-}));
-jest.mock("@/lib/stellar");
-jest.mock("@/components/ToastProvider", () => ({
- useToast: () => ({ addToast: jest.fn() }),
-}));
-
-const now = Date.now();
-
-const userBets: Bet[] = [{ id: "bet-1" } as Bet];
-
-const buildMarket = (overrides: Partial = {}): Market => ({
- id: "m1",
- contractAddress: "CA1",
- fighterA: { name: "Alice", record: "10-0", nationality: "USA", weightClass: "LW" },
- fighterB: { name: "Bob", record: "9-1", nationality: "UK", weightClass: "LW" },
- // scheduledAt acts as resolvedAt proxy; 1 hour ago = within 24h window
- scheduledAt: new Date(now - 1 * 60 * 60 * 1000).toISOString(),
- bettingEndsAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
- status: "Resolved",
- outcome: "FighterA",
- poolA: "5000000000",
- poolB: "3000000000",
- totalPool: "8000000000",
- oracleAddress: "ORA1",
- createdBy: "0xabc",
- ...overrides,
-});
-
-describe("DisputeModal", () => {
- it("renders nothing when market is not Resolved", () => {
- const { container } = render(
-
- );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("renders nothing when outside dispute window (resolved > 24h ago)", () => {
- const { container } = render(
-
- );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("renders nothing when isOpen is false", () => {
- const { container } = render(
-
- );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("renders modal title and textarea when open and within window", () => {
- render(
-
- );
- expect(screen.getByText("Dispute This Result")).toBeInTheDocument();
- expect(screen.getByRole("textbox")).toBeInTheDocument();
- });
-
- it("renders in document.body portal", () => {
- render(
-
- );
- const dialog = document.body.querySelector('[role="dialog"]');
- expect(dialog).toBeTruthy();
- });
-
- it("shows validation error when reason is too short", async () => {
- const user = userEvent.setup();
- render(
-
- );
- await user.type(screen.getByRole("textbox"), "Too short");
- await user.click(screen.getByRole("button", { name: /Submit Dispute/i }));
- expect(screen.getByText(/at least 20 characters/i)).toBeInTheDocument();
- });
-
- it("calls onDisputed when reason is valid and form submitted", async () => {
- const user = userEvent.setup();
- const onDisputed = jest.fn();
- render(
-
- );
- await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters.");
- await user.click(screen.getByRole("button", { name: /Submit Dispute/i }));
- await waitFor(() => expect(onDisputed).toHaveBeenCalledTimes(1));
- });
-
- it("builds dispute_resolution against the market contract address", async () => {
- const user = userEvent.setup();
- render(
-
- );
- await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters.");
- await user.click(screen.getByRole("button", { name: /Submit Dispute/i }));
- await waitFor(() =>
- expect(stellar.buildSorobanInvocation).toHaveBeenCalledWith(
- expect.objectContaining({ contractId: "CA1", method: "dispute_resolution" })
- )
- );
- });
-
- it("closes on backdrop click", async () => {
- const user = userEvent.setup();
- const onClose = jest.fn();
- render();
- // Click the backdrop overlay (the outermost fixed div)
- const backdrop = document.querySelector(".fixed.inset-0") as HTMLElement;
- await user.click(backdrop);
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("closes on Escape key press", () => {
- const onClose = jest.fn();
- render();
- fireEvent.keyDown(document, { key: "Escape" });
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("matches snapshot when open within window", () => {
- const market = buildMarket();
- const { baseElement } = render(
-
- );
- expect(document.body.querySelector('[role="dialog"]')).toMatchSnapshot();
- });
-
- it("matches snapshot when not visible (outside window)", () => {
- const { container } = render(
-
- );
- expect(container).toMatchSnapshot();
- });
-});
diff --git a/frontend/components/DisputeModal.test.tsx b/frontend/components/DisputeModal.test.tsx
index d094745f..96d60ac6 100644
--- a/frontend/components/DisputeModal.test.tsx
+++ b/frontend/components/DisputeModal.test.tsx
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DisputeModal } from "./DisputeModal";
import { Market } from "@/lib/api";
+import * as stellar from "@/lib/stellar";
jest.mock("@/hooks/useWallet", () => ({
useWallet: jest.fn(() => ({
@@ -132,6 +133,20 @@ describe("DisputeModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
+ it("builds dispute_resolution against the market contract address", async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+ await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters.");
+ await user.click(screen.getByRole("button", { name: /Submit Dispute/i }));
+ await waitFor(() =>
+ expect(stellar.buildSorobanInvocation).toHaveBeenCalledWith(
+ expect.objectContaining({ contractId: "CA1", method: "dispute_resolution" })
+ )
+ );
+ });
+
it("matches snapshot when open within window", () => {
jest.useFakeTimers();
jest.setSystemTime(now);
diff --git a/frontend/components/MarketStatusBadge.f105-f108.test.tsx b/frontend/components/MarketStatusBadge.f105-f108.test.tsx
deleted file mode 100644
index a198830b..00000000
--- a/frontend/components/MarketStatusBadge.f105-f108.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from "react";
-import { render, screen } from "@testing-library/react";
-import { MarketStatusBadge } from "./MarketStatusBadge";
-
-describe("MarketStatusBadge outcome labels (F-105)", () => {
- it("shows a distinct Draw badge with refund copy", () => {
- render();
-
- expect(screen.getByText("Draw")).toBeInTheDocument();
- expect(screen.getByText(/full refund/i)).toBeInTheDocument();
- expect(screen.queryByText("Cancelled")).not.toBeInTheDocument();
- });
-
- it("shows a distinct No Contest badge with refund copy", () => {
- render();
-
- expect(screen.getByText("No Contest")).toBeInTheDocument();
- expect(screen.getByText(/full refund/i)).toBeInTheDocument();
- });
-
- it("shows a distinct Admin cancelled badge", () => {
- render();
-
- expect(screen.getByText("Admin cancelled")).toBeInTheDocument();
- });
-
- it("keeps the generic Cancelled label when no reason is given", () => {
- render();
-
- expect(screen.getByText("Cancelled")).toBeInTheDocument();
- });
-});
diff --git a/frontend/components/MarketStatusBadge.test.tsx b/frontend/components/MarketStatusBadge.test.tsx
index 7190d63d..a8e2822a 100644
--- a/frontend/components/MarketStatusBadge.test.tsx
+++ b/frontend/components/MarketStatusBadge.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { MarketStatusBadge } from "./MarketStatusBadge";
import { MarketStatus } from "@/lib/api";
@@ -10,4 +10,33 @@ describe("MarketStatusBadge", () => {
const { container } = render();
expect(container.firstChild).toMatchSnapshot();
});
+
+ // ── Cancel reason badges (merged from f105-f108) ──────────────────────────
+
+ it("shows a distinct Draw badge with refund copy", () => {
+ render();
+
+ expect(screen.getByText("Draw")).toBeInTheDocument();
+ expect(screen.getByText(/full refund/i)).toBeInTheDocument();
+ expect(screen.queryByText("Cancelled")).not.toBeInTheDocument();
+ });
+
+ it("shows a distinct No Contest badge with refund copy", () => {
+ render();
+
+ expect(screen.getByText("No Contest")).toBeInTheDocument();
+ expect(screen.getByText(/full refund/i)).toBeInTheDocument();
+ });
+
+ it("shows a distinct Admin cancelled badge", () => {
+ render();
+
+ expect(screen.getByText("Admin cancelled")).toBeInTheDocument();
+ });
+
+ it("keeps the generic Cancelled label when no reason is given", () => {
+ render();
+
+ expect(screen.getByText("Cancelled")).toBeInTheDocument();
+ });
});
diff --git a/frontend/components/MarketStatusBadge.tsx b/frontend/components/MarketStatusBadge.tsx
index 8eeb5a25..c7962756 100644
--- a/frontend/components/MarketStatusBadge.tsx
+++ b/frontend/components/MarketStatusBadge.tsx
@@ -1,7 +1,12 @@
import { MarketStatus } from "@/lib/api";
+/** Additional reason when a market is cancelled, producing a distinct badge. */
+export type CancelReason = "Draw" | "NoContest" | "AdminCancelled";
+
export interface MarketStatusBadgeProps {
status: MarketStatus;
+ /** Optional reason for a Cancelled status, showing a more specific badge. */
+ cancelReason?: CancelReason;
}
const COLORS: Record = {
@@ -12,10 +17,31 @@ const COLORS: Record = {
Cancelled: "bg-gray-600 text-gray-100",
};
-export function MarketStatusBadge({ status }: MarketStatusBadgeProps): JSX.Element {
+/** Label shown for each cancel reason, or null to keep the generic "Cancelled". */
+const CANCEL_REASON_LABELS: Record = {
+ Draw: "Draw",
+ NoContest: "No Contest",
+ AdminCancelled: "Admin cancelled",
+};
+
+export function MarketStatusBadge({ status, cancelReason }: MarketStatusBadgeProps): JSX.Element {
+ const label =
+ status === "Cancelled" && cancelReason && CANCEL_REASON_LABELS[cancelReason]
+ ? CANCEL_REASON_LABELS[cancelReason]
+ : status;
+
return (
- {status}
+ {status === "Cancelled" && cancelReason ? (
+ <>
+ {label}
+ {cancelReason !== "AdminCancelled" && (
+ (full refund)
+ )}
+ >
+ ) : (
+ label
+ )}
);
}
diff --git a/frontend/components/TransactionStatusModal.f105-f108.test.tsx b/frontend/components/TransactionStatusModal.f105-f108.test.tsx
deleted file mode 100644
index d8f64bc8..00000000
--- a/frontend/components/TransactionStatusModal.f105-f108.test.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from "react";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { TransactionStatusModal } from "./TransactionStatusModal";
-
-describe("TransactionStatusModal accessibility (F-106)", () => {
- it("renders as a labelled modal dialog", () => {
- render();
-
- const dialog = screen.getByRole("dialog", { name: /transaction/i });
- expect(dialog).toHaveAttribute("aria-modal", "true");
- });
-
- it("ignores Escape while a transaction is in flight and announces it", () => {
- const onClose = jest.fn();
- render();
-
- fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
-
- expect(onClose).not.toHaveBeenCalled();
- expect(screen.getByRole("status")).toHaveTextContent(/in progress/i);
- });
-
- it("closes on Escape once the transaction has settled", () => {
- const onClose = jest.fn();
- render();
-
- fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
-
- expect(onClose).toHaveBeenCalledTimes(1);
- });
-
- it("announces status changes through an aria-live region", () => {
- render();
-
- expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
- });
-});