diff --git a/frontend/app/layout.test.tsx b/frontend/app/layout.test.tsx new file mode 100644 index 00000000..eb1c4629 --- /dev/null +++ b/frontend/app/layout.test.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import RootLayout from "./layout"; + +jest.mock("@/app/globals.css", () => ({})); + +describe("RootLayout skip link and focus (F-107)", () => { + it("renders a skip link as the first focusable element", () => { + render(

Body

); + + const skipLink = screen.getByRole("link", { name: /skip to content/i }); + expect(skipLink).toHaveAttribute("href", "#main"); + }); + + it("targets a main landmark with the id used by the skip link", () => { + const { container } = render(

Body

); + + const main = container.querySelector("main"); + expect(main).not.toBeNull(); + expect(main).toHaveAttribute("id", "main"); + }); + + it("keeps the skip link visible when focused", () => { + render(

Body

); + + const skipLink = screen.getByRole("link", { name: /skip to content/i }); + expect(skipLink.className).toMatch(/sr-only/); + expect(skipLink.className).toMatch(/focus:not-sr-only/); + }); + + it("does not clip focus outlines on main", () => { + const { container } = render(

Body

); + + expect(container.querySelector("main")?.className).not.toMatch(/overflow-x-hidden/); + }); +}); diff --git a/frontend/components/DisputeModal.f105-f108.test.tsx b/frontend/components/DisputeModal.f105-f108.test.tsx new file mode 100644 index 00000000..bbe9e37f --- /dev/null +++ b/frontend/components/DisputeModal.f105-f108.test.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DisputeModal } from "./DisputeModal"; +import { Market } from "@/lib/api"; + +const market = { + id: "market-123", + status: "Resolved", +} as unknown as Market; + +describe("DisputeModal accessibility (F-106)", () => { + it("renders as a labelled modal dialog", () => { + render(); + + const dialog = screen.getByRole("dialog", { name: /dispute/i }); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + + it("moves focus into the dialog on open", () => { + render(); + + expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement); + }); + + it("closes on Escape and returns focus to the trigger", () => { + 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"); + }); +}); diff --git a/frontend/components/MarketStatusBadge.f105-f108.test.tsx b/frontend/components/MarketStatusBadge.f105-f108.test.tsx new file mode 100644 index 00000000..a198830b --- /dev/null +++ b/frontend/components/MarketStatusBadge.f105-f108.test.tsx @@ -0,0 +1,32 @@ +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/TransactionStatusModal.f105-f108.test.tsx b/frontend/components/TransactionStatusModal.f105-f108.test.tsx new file mode 100644 index 00000000..d8f64bc8 --- /dev/null +++ b/frontend/components/TransactionStatusModal.f105-f108.test.tsx @@ -0,0 +1,37 @@ +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"); + }); +}); diff --git a/frontend/lib/format.test.ts b/frontend/lib/format.test.ts new file mode 100644 index 00000000..b5dcec0b --- /dev/null +++ b/frontend/lib/format.test.ts @@ -0,0 +1,42 @@ +import { formatXlm, formatDateTime, formatRelative } from "./format"; + +describe("formatXlm (F-108)", () => { + it("formats whole and fractional XLM with grouping and seven decimals trimmed to two", () => { + expect(formatXlm(1234.5, "en-US")).toBe("1,234.50 XLM"); + expect(formatXlm("0.1", "en-US")).toBe("0.10 XLM"); + }); + + it("uses the requested locale separators", () => { + expect(formatXlm(1234.5, "de-DE")).toBe("1.234,50 XLM"); + }); +}); + +describe("formatDateTime (F-108)", () => { + const iso = "2026-07-09T20:00:00Z"; + + it("renders the instant in the given zone with a zone label", () => { + const text = formatDateTime(iso, { locale: "en-US", timeZone: "America/New_York" }); + + expect(text).toContain("4:00 PM"); + expect(text).toMatch(/EDT|GMT-4/); + }); + + it("renders the same instant in UTC with a UTC label", () => { + const text = formatDateTime(iso, { locale: "en-US", timeZone: "UTC" }); + + expect(text).toContain("8:00 PM"); + expect(text).toMatch(/UTC|GMT/); + }); +}); + +describe("formatRelative (F-108)", () => { + const now = new Date("2026-07-09T12:00:00Z"); + + it("describes a future betting close in hours", () => { + expect(formatRelative("2026-07-09T20:00:00Z", { now, locale: "en-US" })).toBe("in 8 hours"); + }); + + it("describes a past close in minutes", () => { + expect(formatRelative("2026-07-09T11:30:00Z", { now, locale: "en-US" })).toBe("30 minutes ago"); + }); +});