diff --git a/apps/web/src/features/trade/components/OracleStalenessIndicator.test.tsx b/apps/web/src/features/trade/components/OracleStalenessIndicator.test.tsx new file mode 100644 index 0000000..5d0b6f2 --- /dev/null +++ b/apps/web/src/features/trade/components/OracleStalenessIndicator.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { render, screen } from "@testing-library/react" +import { OracleStalenessIndicator } from "./OracleStalenessIndicator" + +describe("OracleStalenessIndicator", () => { + it("renders green dot for fresh staleness", () => { + const { container } = render() + const dot = container.querySelector(".bg-green-500") + expect(dot).toBeInTheDocument() + }) + + it("renders yellow dot for warning staleness", () => { + const { container } = render() + const dot = container.querySelector(".bg-yellow-500") + expect(dot).toBeInTheDocument() + }) + + it("renders red dot for stale staleness", () => { + const { container } = render() + const dot = container.querySelector(".bg-red-500") + expect(dot).toBeInTheDocument() + }) + + it("does not show label when showLabel is false", () => { + render() + expect(screen.queryByText("Stale")).not.toBeInTheDocument() + }) + + it("shows Stale label when showLabel is true and staleness is stale", () => { + render() + expect(screen.getByText("Stale")).toBeInTheDocument() + }) + + it("does not show Stale label when showLabel is true but staleness is fresh", () => { + render() + expect(screen.queryByText("Stale")).not.toBeInTheDocument() + }) + + it("does not show Stale label when showLabel is true but staleness is warning", () => { + render() + expect(screen.queryByText("Stale")).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx b/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx new file mode 100644 index 0000000..663bd1e --- /dev/null +++ b/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { useWalletStore } from "@/features/wallet/store/wallet-store" + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +vi.mock("@/lib/contracts", () => ({ + ExchangeRouterClient: class {}, + SyntheticsReaderClient: class {}, + exchangeRouterClient: {}, + syntheticsReaderClient: {}, + buildCreateOrderTransaction: vi.fn(), + buildCancelOrderTransaction: vi.fn(), +})) + +vi.mock("@/features/trade/hooks/useTokenPrices", () => ({ + useTokenPrices: () => ({ + getMidPrice: () => 1, + isStale: () => false, + getPrice: () => ({ minPrice: 1, maxPrice: 1 }), + getStaleness: () => "fresh" as const, + }), +})) + +const mockBalances: { data: Record | undefined } = { data: undefined } + +vi.mock("@/features/wallet/hooks/useTokenBalances", () => ({ + useTokenBalances: () => ({ + data: mockBalances.data, + isLoading: false, + }), +})) + +vi.mock("@/features/trade/lib/stellar", () => ({ + cancelOrder: vi.fn(), + createIncreaseOrder: vi.fn(), + createDecreaseOrder: vi.fn(), + claimFundingFees: vi.fn(), +})) + +function createMockPosition(overrides: Record = {}) { + return { + key: "pos-1", + account: "GABCDEF123456789", + marketAddress: "0xbtc", + marketName: "BTC/USD", + indexToken: "WBTC", + collateralToken: "USDC", + collateralAmount: 1, + collateralUsd: 100000, + sizeUsd: 50000, + sizeInUsdRaw: 50000000000n, + entryPrice: 50000, + markPrice: 51000, + liquidationPrice: 45000, + leverage: 10, + pnl: 1000, + pnlPercent: 10, + isLong: true, + pnlAfterFees: 950, + fundingFeeUsd: 50, + ...overrides, + } +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let CollateralDialog: typeof import("./CollateralDialog").CollateralDialog + +describe("CollateralDialog", () => { + beforeEach(async () => { + CollateralDialog = (await import("./CollateralDialog")).CollateralDialog + useWalletStore.setState({ + address: "GABCDEF123456789", + walletId: null, + status: "connected", + network: "testnet", + pendingTransactionXdr: null, + }) + mockBalances.data = { USDC: 10000, XLM: 100 } + }) + + afterEach(() => { + cleanup() + document.body.innerHTML = "" + }) + + it("renders nothing when position is null", () => { + const { container } = render( + , + { wrapper: createWrapper() }, + ) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when mode is null", () => { + const position = createMockPosition() + const { container } = render( + , + { wrapper: createWrapper() }, + ) + expect(container.firstChild).toBeNull() + }) + + it("renders add collateral dialog with correct title", () => { + const position = createMockPosition() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByRole("heading", { name: /Add Collateral.*BTC/ })).toBeInTheDocument() + }) + + it("renders remove collateral dialog with correct title", () => { + const position = createMockPosition() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByRole("heading", { name: /Remove Collateral.*BTC/ })).toBeInTheDocument() + }) + + it("shows wallet balance in add mode", () => { + const position = createMockPosition() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getAllByText(/Wallet Balance/).length).toBeGreaterThanOrEqual(1) + expect(screen.getByText("10,000 USDC")).toBeInTheDocument() + }) + + it("shows Use Max button in add mode", () => { + const position = createMockPosition() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText("Use Max")).toBeInTheDocument() + }) + + it("does not show Use Max button in remove mode", () => { + const position = createMockPosition() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.queryByText("Use Max")).not.toBeInTheDocument() + }) + + it("validates insufficient wallet balance in add mode", async () => { + mockBalances.data = { USDC: 5, XLM: 100 } + const position = createMockPosition() + const user = userEvent.setup() + render( + , + { wrapper: createWrapper() }, + ) + const inputs = screen.getAllByPlaceholderText("0.00") + const input = inputs[inputs.length - 1] + await user.type(input, "10") + expect(screen.getByText("Insufficient wallet balance")).toBeInTheDocument() + }) + + it("validates cannot remove all collateral in remove mode", async () => { + const position = createMockPosition({ collateralAmount: 1 }) + const user = userEvent.setup() + render( + , + { wrapper: createWrapper() }, + ) + const inputs = screen.getAllByPlaceholderText("0.00") + const input = inputs[inputs.length - 1] + await user.type(input, "1") + expect(screen.getByText(/Cannot remove all collateral/)).toBeInTheDocument() + }) + + it("disables confirm button when amount is empty", () => { + const position = createMockPosition() + render( + , + { wrapper: createWrapper() }, + ) + const confirmButton = screen.getByRole("button", { name: /Confirm Add Collateral/ }) + expect(confirmButton).toBeDisabled() + }) + + it("enables confirm button with valid amount in add mode", async () => { + mockBalances.data = { USDC: 10000, XLM: 100 } + const position = createMockPosition() + const user = userEvent.setup() + render( + , + { wrapper: createWrapper() }, + ) + const inputs = screen.getAllByPlaceholderText("0.00") + const input = inputs[inputs.length - 1] + await user.type(input, "100") + const confirmButton = screen.getByRole("button", { name: /Confirm Add Collateral/ }) + expect(confirmButton).toBeEnabled() + }) + + it("calls onClose when cancel is clicked", async () => { + const position = createMockPosition() + const onClose = vi.fn() + const user = userEvent.setup() + render( + , + { wrapper: createWrapper() }, + ) + const cancelButton = screen.getByRole("button", { name: "Cancel" }) + await user.click(cancelButton) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/web/src/features/trade/components/positions/OrdersList.test.tsx b/apps/web/src/features/trade/components/positions/OrdersList.test.tsx new file mode 100644 index 0000000..b035c58 --- /dev/null +++ b/apps/web/src/features/trade/components/positions/OrdersList.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +let mockData: Array> = [] +let mockIsLoading = false +let mockIsDisabled = false + +vi.mock("@/features/trade/hooks/useOrdersWithIndexer", () => ({ + useOrdersWithIndexer: () => ({ + data: mockData, + isLoading: mockIsLoading, + isDisabled: mockIsDisabled, + }), +})) + +vi.mock("@/lib/contracts", () => ({ + ExchangeRouterClient: class {}, + SyntheticsReaderClient: class {}, + exchangeRouterClient: {}, + syntheticsReaderClient: {}, + buildCancelOrderTransaction: vi.fn(), +})) + +vi.mock("@/features/trade/lib/stellar", () => ({ + cancelOrder: vi.fn(), + createIncreaseOrder: vi.fn(), + createDecreaseOrder: vi.fn(), + claimFundingFees: vi.fn(), +})) + +function createMockOrder(overrides: Record = {}) { + return { + key: "order-1", + account: "GABCDEF123456789", + marketAddress: "0xbtc", + marketName: "BTC/USD", + orderType: "MarketIncrease", + status: "active", + isLong: true, + sizeUsd: 50000, + triggerPrice: 0, + updatedAt: Date.now(), + ...overrides, + } +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let OrdersList: typeof import("./OrdersList").OrdersList + +describe("OrdersList", () => { + beforeEach(async () => { + OrdersList = (await import("./OrdersList")).OrdersList + mockData = [] + mockIsLoading = false + mockIsDisabled = false + }) + + afterEach(() => { + cleanup() + document.body.innerHTML = "" + }) + + it("renders loading skeleton when loading", () => { + mockIsLoading = true + const { container } = render(, { wrapper: createWrapper() }) + expect(container.querySelector(".animate-pulse")).toBeInTheDocument() + }) + + it("renders empty state when no orders", () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText("No open orders")).toBeInTheDocument() + }) + + it("renders empty state with indexer disabled message", () => { + mockIsDisabled = true + render(, { wrapper: createWrapper() }) + expect(screen.getByText(/indexer disabled/i)).toBeInTheDocument() + }) + + it("renders active orders with market name and type", () => { + mockData = [createMockOrder()] + render(, { wrapper: createWrapper() }) + expect(screen.getByText("BTC/USD")).toBeInTheDocument() + expect(screen.getByText("Long")).toBeInTheDocument() + expect(screen.getByText("MarketIncrease")).toBeInTheDocument() + }) + + it("renders multiple orders", () => { + mockData = [ + createMockOrder({ key: "order-1", marketName: "BTC/USD" }), + createMockOrder({ key: "order-2", marketName: "ETH/USD" }), + ] + render(, { wrapper: createWrapper() }) + expect(screen.getByText("BTC/USD")).toBeInTheDocument() + expect(screen.getByText("ETH/USD")).toBeInTheDocument() + }) + + it("renders frozen status badge", () => { + mockData = [createMockOrder({ status: "frozen" })] + render(, { wrapper: createWrapper() }) + expect(screen.getByText("Frozen")).toBeInTheDocument() + }) + + it("renders cancel button for each order", () => { + mockData = [ + createMockOrder({ key: "order-1" }), + createMockOrder({ key: "order-2" }), + ] + render(, { wrapper: createWrapper() }) + const cancelButtons = screen.getAllByRole("button", { name: "Cancel" }) + expect(cancelButtons).toHaveLength(2) + }) +}) diff --git a/apps/web/src/features/trade/components/positions/PositionsList.test.tsx b/apps/web/src/features/trade/components/positions/PositionsList.test.tsx new file mode 100644 index 0000000..897cbb1 --- /dev/null +++ b/apps/web/src/features/trade/components/positions/PositionsList.test.tsx @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { useWalletStore } from "@/features/wallet/store/wallet-store" + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children} + } +} + +let mockPositions: Array> = [] +let mockIsLoading = false +let mockIsDisabled = false +let mockFundingRate = { ratePerHour: 0.001, nextEpochTs: Date.now() + 3600000 } + +vi.mock("@/features/trade/hooks/usePositionsWithIndexer", () => ({ + usePositionsWithIndexer: () => ({ + data: mockPositions, + isLoading: mockIsLoading, + isDisabled: mockIsDisabled, + }), +})) + +vi.mock("@/features/trade/hooks/useFundingRate", () => ({ + useFundingRate: () => ({ + data: mockFundingRate, + }), +})) + +vi.mock("@/lib/contracts", () => ({ + ExchangeRouterClient: class {}, + SyntheticsReaderClient: class {}, + exchangeRouterClient: {}, + syntheticsReaderClient: {}, +})) + +vi.mock("@/features/trade/lib/stellar", () => ({ + cancelOrder: vi.fn(), + createIncreaseOrder: vi.fn(), + createDecreaseOrder: vi.fn(), + claimFundingFees: vi.fn(), +})) + +vi.mock("@/features/trade/hooks/useTokenPrices", () => ({ + useTokenPrices: () => ({ + getMidPrice: () => 1, + isStale: () => false, + getPrice: () => ({ minPrice: 1, maxPrice: 1 }), + getStaleness: () => "fresh" as const, + }), +})) + +vi.mock("@/features/wallet/hooks/useTokenBalances", () => ({ + useTokenBalances: () => ({ + data: {} as Record, + isLoading: false, + }), +})) + +vi.mock("@/features/trade/hooks/useTokenList", () => ({ + useTokenList: () => ({ + data: [], + isLoading: false, + }), +})) + +vi.mock("@/shared/components/TokenIcon", () => ({ + TokenIcon: ({ symbol, size }: { symbol: string; size: number }) => ( + + {symbol} + + ), +})) + +function createMockPosition(overrides: Record = {}) { + return { + key: "pos-1", + account: "GABCDEF123456789", + marketAddress: "0xbtc", + marketName: "BTC/USD", + indexToken: "WBTC", + collateralToken: "USDC", + collateralAmount: 1, + collateralUsd: 100000, + sizeUsd: 50000, + sizeInUsdRaw: 50000000000n, + entryPrice: 50000, + markPrice: 51000, + liquidationPrice: 45000, + leverage: 10, + pnl: 1000, + pnlPercent: 10, + isLong: true, + pnlAfterFees: 950, + fundingFeeUsd: 50, + ...overrides, + } +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let PositionsList: typeof import("./PositionsList").PositionsList + +describe("PositionsList", () => { + beforeEach(async () => { + PositionsList = (await import("./PositionsList")).PositionsList + mockPositions = [] + mockIsLoading = false + mockIsDisabled = false + mockFundingRate = { ratePerHour: 0.001, nextEpochTs: Date.now() + 3600000 } + useWalletStore.setState({ + address: null, + walletId: null, + status: "disconnected", + network: "testnet", + pendingTransactionXdr: null, + }) + }) + + afterEach(() => { + cleanup() + document.body.innerHTML = "" + }) + + it("renders loading skeleton when loading", () => { + mockIsLoading = true + const { container } = render(, { wrapper: createWrapper() }) + expect(container.querySelector(".animate-pulse")).toBeInTheDocument() + }) + + it("renders empty state when no positions", () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText("No open positions")).toBeInTheDocument() + }) + + it("renders empty state with start trading link", () => { + render(, { wrapper: createWrapper() }) + expect(screen.getByText("Start trading →")).toBeInTheDocument() + }) + + it("renders positions with market name, side badge, and size", () => { + mockPositions = [createMockPosition()] + render(, { wrapper: createWrapper() }) + expect(screen.getByText("BTC/USD")).toBeInTheDocument() + expect(screen.getByText("Long")).toBeInTheDocument() + }) + + it("renders multiple positions", () => { + mockPositions = [ + createMockPosition({ key: "pos-1", marketName: "BTC/USD" }), + createMockPosition({ key: "pos-2", marketName: "ETH/USD" }), + ] + render(, { wrapper: createWrapper() }) + expect(screen.getByText("BTC/USD")).toBeInTheDocument() + expect(screen.getByText("ETH/USD")).toBeInTheDocument() + }) + + it("renders short position badge", () => { + mockPositions = [createMockPosition({ isLong: false })] + render(, { wrapper: createWrapper() }) + expect(screen.getByText("Short")).toBeInTheDocument() + }) + + it("renders action buttons for each position", () => { + mockPositions = [createMockPosition()] + render(, { wrapper: createWrapper() }) + expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "+ Collateral" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "- Collateral" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument() + }) + + it("renders funding fee when positive", () => { + mockPositions = [createMockPosition({ fundingFeeUsd: 50 })] + render(, { wrapper: createWrapper() }) + expect(screen.getByRole("button", { name: "Claim" })).toBeInTheDocument() + }) + + it("does not render Claim button when funding fee is zero", () => { + mockPositions = [createMockPosition({ fundingFeeUsd: 0 })] + render(, { wrapper: createWrapper() }) + expect(screen.queryByRole("button", { name: "Claim" })).not.toBeInTheDocument() + }) + + it("renders indexer disabled message when disabled", () => { + mockIsDisabled = true + render(, { wrapper: createWrapper() }) + expect(screen.getByText(/indexer disabled/i)).toBeInTheDocument() + }) + + it("shows positive PnL in green", () => { + mockPositions = [createMockPosition({ pnlAfterFees: 950 })] + render(, { wrapper: createWrapper() }) + const pnlElement = screen.getByText(/\$950\.00/) + expect(pnlElement).toBeInTheDocument() + expect(pnlElement.className).toContain("text-green-500") + }) + + it("shows negative PnL in red", () => { + mockPositions = [createMockPosition({ pnlAfterFees: -500 })] + render(, { wrapper: createWrapper() }) + const pnlElement = screen.getByText((content) => content.includes("-$") && content.includes("500")) + expect(pnlElement).toBeInTheDocument() + }) +})