diff --git a/.changeset/wise-rivers-change.md b/.changeset/wise-rivers-change.md new file mode 100644 index 000000000000..7a9dc68cd2df --- /dev/null +++ b/.changeset/wise-rivers-change.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": minor +--- + +improve market coverage on mvvm diff --git a/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/RowItemView.test.tsx b/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/RowItemView.test.tsx new file mode 100644 index 000000000000..fcb6f8c51151 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/RowItemView.test.tsx @@ -0,0 +1,213 @@ +import React from "react"; +import { render, screen } from "tests/testSetup"; +import { RowItemView } from "../RowItemView"; +import { MOCK_MARKET_CURRENCY_DATA } from "@ledgerhq/live-common/market/utils/fixtures"; +import { MarketAction, RowItemViewProps } from "../types"; + +const mockCurrency = MOCK_MARKET_CURRENCY_DATA[0]; + +const mockOnBuy = jest.fn(); +const mockOnSwap = jest.fn(); +const mockOnStake = jest.fn(); + +const buyAction: MarketAction = { type: "buy", label: "Buy", onClick: mockOnBuy }; +const swapAction: MarketAction = { type: "swap", label: "Swap", onClick: mockOnSwap }; +const stakeAction: MarketAction = { type: "stake", label: "Earn", onClick: mockOnStake }; + +function createDefaultProps(overrides: Partial = {}): RowItemViewProps { + return { + style: {}, + currency: mockCurrency, + counterCurrency: "usd", + locale: "en", + isStarred: false, + hasActions: false, + actions: [], + currentPriceChangePercentage: 2.5, + onCurrencyClick: jest.fn(), + onStarClick: jest.fn(), + ...overrides, + }; +} + +describe("RowItemView", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render currency name and ticker", () => { + render(); + + expect(screen.getByText("Bitcoin")).toBeVisible(); + expect(screen.getByText("BTC")).toBeVisible(); + }); + + it("should render marketcap rank", () => { + render(); + + expect(screen.getByText("1")).toBeVisible(); + }); + + it("should render row when marketcapRank is present", () => { + render(); + + const row = screen.getByTestId("market-BTC-row"); + expect(row).toBeVisible(); + }); + + it("should render CryptoIcon when ledgerIds has entries", () => { + render(); + + expect(screen.queryByAltText("currency logo")).toBeNull(); + }); + + it("should render img fallback when ledgerIds is empty", () => { + const currency = { ...mockCurrency, ledgerIds: [] }; + render(); + + expect(screen.getByAltText("currency logo")).toBeVisible(); + }); + + it("should show Buy/Swap/Stake buttons when hasActions and all available", () => { + render( + , + ); + + expect(screen.getByTestId("market-BTC-buy-button")).toBeVisible(); + expect(screen.getByTestId("market-BTC-swap-button")).toBeVisible(); + expect(screen.getByTestId("market-BTC-stake-button")).toBeVisible(); + }); + + it("should hide action buttons when hasActions is false", () => { + render(); + + expect(screen.queryByTestId("market-BTC-buy-button")).toBeNull(); + expect(screen.queryByTestId("market-BTC-swap-button")).toBeNull(); + expect(screen.queryByTestId("market-BTC-stake-button")).toBeNull(); + }); + + it("should show only Buy button when only buy action is provided", () => { + render( + , + ); + + expect(screen.getByTestId("market-BTC-buy-button")).toBeVisible(); + expect(screen.queryByTestId("market-BTC-swap-button")).toBeNull(); + expect(screen.queryByTestId("market-BTC-stake-button")).toBeNull(); + }); + + it("should render '-' when currentPriceChangePercentage is undefined", () => { + render(); + + const priceChangeCell = screen.getByTestId("market-price-change"); + expect(priceChangeCell).toHaveTextContent("-"); + }); + + it("should render FormattedVal when currentPriceChangePercentage is defined", () => { + render(); + + const priceChangeCell = screen.getByTestId("market-price-change"); + expect(priceChangeCell).not.toHaveTextContent("-"); + }); + + it("should render star button when isStarred is true", () => { + render(); + + expect(screen.getByTestId("market-BTC-star-button")).toBeVisible(); + }); + + it("should render star button when isStarred is false", () => { + render(); + + expect(screen.getByTestId("market-BTC-star-button")).toBeVisible(); + }); + + it("should call onCurrencyClick on row click", async () => { + const onCurrencyClick = jest.fn(); + const { user } = render(); + + await user.click(screen.getByTestId("market-BTC-row")); + expect(onCurrencyClick).toHaveBeenCalledTimes(1); + }); + + it("should call onStarClick on star button click", async () => { + const onStarClick = jest.fn(); + const { user } = render(); + + await user.click(screen.getByTestId("market-BTC-star-button")); + expect(onStarClick).toHaveBeenCalledTimes(1); + }); + + it("should call action onClick when Buy button is clicked", async () => { + const { user } = render( + , + ); + + await user.click(screen.getByTestId("market-BTC-buy-button")); + expect(mockOnBuy).toHaveBeenCalledTimes(1); + }); + + it("should call action onClick when Swap button is clicked", async () => { + const { user } = render( + , + ); + + await user.click(screen.getByTestId("market-BTC-swap-button")); + expect(mockOnSwap).toHaveBeenCalledTimes(1); + }); + + it("should call action onClick when Stake button is clicked", async () => { + const { user } = render( + , + ); + + await user.click(screen.getByTestId("market-BTC-stake-button")); + expect(mockOnStake).toHaveBeenCalledTimes(1); + }); + + it("should render sparkline chart when sparklineIn7d exists", async () => { + const currency = { + ...mockCurrency, + sparklineIn7d: { path: "M0 0L1 1", viewBox: "0 0 100 50", isPositive: true }, + }; + const { user } = render(); + + await user.hover(screen.getByTestId("market-small-graph")); + + const graphCell = screen.getByTestId("market-small-graph"); + expect(graphCell.querySelector("svg")).toBeVisible(); + }); + + it("should not render sparkline chart when sparklineIn7d is undefined", () => { + const currency = { ...mockCurrency, sparklineIn7d: undefined }; + render(); + + const graphCell = screen.getByTestId("market-small-graph"); + expect(graphCell.querySelector("svg")).toBeNull(); + }); +}); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/useRowItemViewModel.test.ts b/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/useRowItemViewModel.test.ts index f7f867075e1e..2ef44a905459 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/useRowItemViewModel.test.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Market/components/RowItem/__tests__/useRowItemViewModel.test.ts @@ -15,8 +15,15 @@ jest.mock("react-router", () => ({ })); jest.mock("LLD/features/Market/hooks/useMarketActions", () => ({ - Page: { Market: "Page Market" }, - useMarketActions: jest.fn(), + Page: { Market: "Page Market", MarketCoin: "Page Market Coin" }, + useMarketActions: jest.fn(() => ({ + onBuy: mockOnBuy, + onSwap: mockOnSwap, + onStake: mockOnStake, + availableOnBuy: false, + availableOnSwap: false, + availableOnStake: false, + })), })); jest.mock("~/renderer/hooks/useGetStakeLabelLocaleBased", () => ({ @@ -70,6 +77,28 @@ describe("useRowItemViewModel", () => { expect(result.current.hasActions).toBe(false); }); + it("returns hasActions=false when all availableOn flags are false", () => { + mockedUseMarketActions.mockReturnValue({ + onBuy: mockOnBuy, + onSwap: mockOnSwap, + onStake: mockOnStake, + availableOnBuy: false, + availableOnSwap: false, + availableOnStake: false, + }); + + const { result } = renderHook(() => + useRowItemViewModel({ + currency: bitcoinCurrency, + toggleStar: jest.fn(), + range: "24h", + }), + ); + + expect(result.current.actions).toEqual([]); + expect(result.current.hasActions).toBe(false); + }); + it("returns only available actions when currency has ledgerIds", () => { mockedUseMarketActions.mockReturnValue({ ...allActionsAvailable, @@ -138,6 +167,22 @@ describe("useRowItemViewModel", () => { }); }); + it("does not navigate when currency is null", () => { + const { result } = renderHook(() => + useRowItemViewModel({ + currency: null, + toggleStar: jest.fn(), + range: "24h", + }), + ); + + act(() => { + result.current.onCurrencyClick(); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("onStarClick calls toggleStar and prevents propagation", () => { const mockToggleStar = jest.fn(); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Market/components/__tests__/ListData.test.tsx b/apps/ledger-live-desktop/src/mvvm/features/Market/components/__tests__/ListData.test.tsx new file mode 100644 index 000000000000..4485be19df41 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Market/components/__tests__/ListData.test.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { render, screen } from "tests/testSetup"; +import type { Virtualizer } from "@tanstack/react-virtual"; +import { ListData } from "../ListData"; +import { MOCK_MARKET_CURRENCY_DATA } from "@ledgerhq/live-common/market/utils/fixtures"; + +jest.mock("../RowItem/useRowItemViewModel", () => ({ + useRowItemViewModel: ({ toggleStar }: { toggleStar: () => void }) => ({ + onCurrencyClick: jest.fn(), + onStarClick: (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + toggleStar(); + }, + actions: [], + hasActions: false, + currentPriceChangePercentage: 2.5, + }), +})); + +function createVirtualItem(index: number, start: number, size: number) { + return { index, start, size, end: start + size, key: index, lane: 0 }; +} + +function createMockVirtualizer( + virtualItems: Array>, + totalSize = 1000, +) { + const getVirtualItemsFn = Object.assign(() => virtualItems, { + updateDeps: (_newDeps: [number[], unknown[]]) => {}, + }); + // @ts-expect-error partial mock for testing + const mock: Virtualizer = { + getTotalSize: () => totalSize, + getVirtualItems: getVirtualItemsFn, + }; + return mock; +} + +describe("ListData", () => { + const starredMarketCoins: string[] = []; + const defaultProps = { + marketData: MOCK_MARKET_CURRENCY_DATA, + starredMarketCoins, + counterCurrency: "usd", + locale: "en", + range: "24h", + toggleStar: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render all currencies from virtualizer items", () => { + const rowVirtualizer = createMockVirtualizer([ + createVirtualItem(0, 0, 50), + createVirtualItem(1, 50, 50), + ]); + + render(); + + expect(screen.getByTestId("market-BTC-row")).toBeVisible(); + expect(screen.getByTestId("market-ETH-row")).toBeVisible(); + }); + + it("should render currency name and ticker", () => { + const rowVirtualizer = createMockVirtualizer([createVirtualItem(0, 0, 50)]); + + render(); + + expect(screen.getByText("Bitcoin")).toBeVisible(); + expect(screen.getByText("BTC")).toBeVisible(); + }); + + it("should skip rendering when currency is undefined", () => { + const rowVirtualizer = createMockVirtualizer([ + createVirtualItem(0, 0, 50), + createVirtualItem(5, 50, 50), + ]); + + render(); + + expect(screen.getByTestId("market-BTC-row")).toBeVisible(); + expect(screen.getAllByRole("row")).toHaveLength(1); + }); + + it("should render star button for starred currency", () => { + const rowVirtualizer = createMockVirtualizer([createVirtualItem(0, 0, 50)]); + + render( + , + ); + + expect(screen.getByTestId("market-BTC-star-button")).toBeVisible(); + }); + + it("should render star button for non-starred currency", () => { + const rowVirtualizer = createMockVirtualizer([createVirtualItem(0, 0, 50)]); + + render(); + + expect(screen.getByTestId("market-BTC-star-button")).toBeVisible(); + }); + + it("should call toggleStar with correct arguments when star button is clicked", async () => { + const toggleStar = jest.fn(); + const rowVirtualizer = createMockVirtualizer([createVirtualItem(0, 0, 50)]); + + const { user } = render( + , + ); + + await user.click(screen.getByTestId("market-BTC-star-button")); + expect(toggleStar).toHaveBeenCalledWith("bitcoin", false); + }); + + it("should call toggleStar with isStarred=true for starred coins", async () => { + const toggleStar = jest.fn(); + const rowVirtualizer = createMockVirtualizer([createVirtualItem(0, 0, 50)]); + + const { user } = render( + , + ); + + await user.click(screen.getByTestId("market-BTC-star-button")); + expect(toggleStar).toHaveBeenCalledWith("bitcoin", true); + }); + + it("should set container height from virtualizer total size", () => { + const rowVirtualizer = createMockVirtualizer([], 500); + + render(); + + const container = screen.getByTestId("market-list-data"); + expect(container).toHaveStyle({ height: "500px" }); + }); +}); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/shared.ts b/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/shared.ts new file mode 100644 index 000000000000..28ba9070d521 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/shared.ts @@ -0,0 +1,11 @@ +export const MARKET_API = "https://countervalues.live.ledger.com/v3/markets"; +export const DADA_API = "https://dada.api.ledger.com/v1/assets"; + +export const EMPTY_DADA_RESPONSE = { + cryptoAssets: {}, + networks: {}, + cryptoOrTokenCurrencies: {}, + interestRates: {}, + markets: {}, + currenciesOrder: { key: "marketCap", order: "desc", metaCurrencyIds: [] }, +}; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/useMarketActions.test.ts b/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/useMarketActions.test.ts new file mode 100644 index 000000000000..251b81366ab2 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/useMarketActions.test.ts @@ -0,0 +1,180 @@ +import { renderHook, act, waitFor } from "tests/testSetup"; +import { server } from "tests/server"; +import { http, HttpResponse } from "msw"; +import { useMarketActions, Page } from "../useMarketActions"; +import { MOCK_MARKET_CURRENCY_DATA } from "@ledgerhq/live-common/market/utils/fixtures"; +import { useRampCatalog } from "@ledgerhq/live-common/platform/providers/RampCatalogProvider/useRampCatalog"; +import { useFetchCurrencyAll } from "@ledgerhq/live-common/exchange/swap/hooks/index"; +import { DADA_API, EMPTY_DADA_RESPONSE } from "./shared"; + +const mockNavigateToSwap = jest.fn(); +const mockNavigateToBuy = jest.fn(); +const mockStartStakeFlow = jest.fn(); + +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useLocation: () => ({ pathname: "/market" }), +})); + +jest.mock("@ledgerhq/live-common/platform/providers/RampCatalogProvider/useRampCatalog"); +jest.mock("@ledgerhq/live-common/exchange/swap/hooks/index"); + +jest.mock("../useSwapNavigation", () => ({ + useSwapNavigation: () => ({ navigateToSwap: mockNavigateToSwap }), +})); + +jest.mock("../useBuyNavigation", () => ({ + useBuyNavigation: () => ({ navigateToBuy: mockNavigateToBuy }), +})); + +jest.mock("~/renderer/screens/stake", () => () => mockStartStakeFlow); + +jest.mock("~/renderer/screens/exchange/Swap2/utils", () => ({ + useGetSwapTrackingProperties: () => ({ swapVersion: "2" }), +})); + +jest.mock("LLD/hooks/useStake", () => ({ + useStake: () => ({ + getCanStakeCurrency: (id: string) => id === "bitcoin", + enabledCurrencies: ["bitcoin"], + partnerSupportedAssets: [], + getCanStakeUsingPlatformApp: () => false, + getCanStakeUsingLedgerLive: () => false, + getRouteToPlatformApp: () => null, + }), +})); + +const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), +} as unknown as React.SyntheticEvent; + +const mockCurrency = MOCK_MARKET_CURRENCY_DATA[0]; + +const renderMarketActionsHook = (currency = mockCurrency, page = Page.Market) => + renderHook(() => useMarketActions({ currency, page })); + +describe("useMarketActions", () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.mocked(useRampCatalog).mockReturnValue({ + isCurrencyAvailable: () => true, + } as unknown as ReturnType); + + jest.mocked(useFetchCurrencyAll).mockReturnValue({ + data: ["bitcoin", "ethereum"], + } as unknown as ReturnType); + }); + + it("should return availableOnBuy=true when currency is supported and buy is available", () => { + const { result } = renderMarketActionsHook(); + + expect(result.current.availableOnBuy).toBe(true); + }); + + it("should return availableOnBuy=false when currency ledgerIds are deactivated", () => { + const deactivatedCurrency = { ...mockCurrency, ledgerIds: ["arbitrum"] }; + + const { result } = renderMarketActionsHook(deactivatedCurrency); + + expect(result.current.availableOnBuy).toBe(false); + }); + + it("should return availableOnSwap=true when currency ledgerId is in the swap set", () => { + const { result } = renderMarketActionsHook(); + + expect(result.current.availableOnSwap).toBe(true); + }); + + it("should return availableOnSwap=false when currency is not in swap set", () => { + jest.mocked(useFetchCurrencyAll).mockReturnValue({ + data: ["solana"], + } as unknown as ReturnType); + + const { result } = renderMarketActionsHook(); + + expect(result.current.availableOnSwap).toBe(false); + }); + + it("should return availableOnStake=true when getCanStakeCurrency returns true", () => { + const { result } = renderMarketActionsHook(); + + expect(result.current.availableOnStake).toBe(true); + }); + + it("onBuy should call getLedgerCurrency and navigateToBuy", async () => { + const { result } = renderMarketActionsHook(); + + await act(async () => { + await result.current.onBuy(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(mockNavigateToBuy).toHaveBeenCalledWith( + expect.objectContaining({ id: "bitcoin" }), + mockCurrency.ticker, + ); + }); + + it("onSwap should call getLedgerCurrency and navigateToSwap", async () => { + const { result } = renderMarketActionsHook(); + + await act(async () => { + await result.current.onSwap(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + + expect(mockNavigateToSwap).toHaveBeenCalledWith(expect.objectContaining({ id: "bitcoin" })); + }); + + it("onSwap should return early if ledgerCurrency.id is falsy", async () => { + server.use(http.get(DADA_API, () => HttpResponse.json(EMPTY_DADA_RESPONSE))); + + const { result } = renderMarketActionsHook(); + + await act(async () => { + await result.current.onSwap(mockEvent); + }); + + expect(mockNavigateToSwap).not.toHaveBeenCalled(); + }); + + it("onStake should call getLedgerCurrency, track, and startStakeFlow", async () => { + const { result } = renderMarketActionsHook(); + + await act(async () => { + await result.current.onStake(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + + expect(mockStartStakeFlow).toHaveBeenCalledWith( + expect.objectContaining({ + currencies: ["bitcoin"], + source: Page.Market, + returnTo: "/market", + }), + ); + }); + + it("onStake should use currency ticker when getLedgerCurrency returns null", async () => { + server.use(http.get(DADA_API, () => HttpResponse.json(EMPTY_DADA_RESPONSE))); + + const { result } = renderMarketActionsHook(); + + await act(async () => { + await result.current.onStake(mockEvent); + }); + + await waitFor(() => { + expect(mockStartStakeFlow).toHaveBeenCalledWith( + expect.objectContaining({ currencies: undefined }), + ); + }); + }); +}); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/useMarketCoin.test.ts b/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/useMarketCoin.test.ts new file mode 100644 index 000000000000..94df79998f16 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Market/hooks/__tests__/useMarketCoin.test.ts @@ -0,0 +1,246 @@ +import { renderHook, act, waitFor } from "tests/testSetup"; +import { server } from "tests/server"; +import { http, HttpResponse } from "msw"; +import { useMarketCoin } from "../useMarketCoin"; +import { Order } from "@ledgerhq/live-common/market/utils/types"; +import { MARKET_API, DADA_API, EMPTY_DADA_RESPONSE } from "./shared"; + +const mockOnBuy = jest.fn(); +const mockOnSwap = jest.fn(); +const mockOnStake = jest.fn(); + +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useParams: jest.fn(() => ({ currencyId: "bitcoin" })), +})); + +jest.mock("~/renderer/getCurrencyColor", () => ({ + getCurrencyColor: jest.fn(() => "#FF9900"), +})); + +jest.mock("../useMarketActions", () => ({ + Page: { Market: "Page Market", MarketCoin: "Page Market Coin" }, + useMarketActions: () => ({ + onBuy: mockOnBuy, + onSwap: mockOnSwap, + onStake: mockOnStake, + availableOnBuy: true, + availableOnSwap: true, + availableOnStake: false, + }), +})); + +const { useParams } = jest.requireMock("react-router"); +const { getCurrencyColor } = jest.requireMock("~/renderer/getCurrencyColor"); + +const createMarketState = (overrides = {}) => ({ + marketParams: { + starred: [], + range: "24h", + limit: 50, + order: Order.MarketCapDesc, + search: "", + liveCompatible: false, + page: 1, + counterCurrency: "usd", + ...overrides, + }, + currentPage: 1, +}); + +const createSettingsState = (starredMarketCoins: string[] = []) => ({ + starredMarketCoins, + overriddenFeatureFlags: { + lldRefreshMarketData: { enabled: false }, + }, +}); + +const defaultStarredMarketCoins: string[] = []; + +const renderMarketCoinHook = ({ + marketOverrides = {}, + starredMarketCoins = defaultStarredMarketCoins, +} = {}) => + renderHook(() => useMarketCoin(), { + minimal: false, + initialState: { + market: createMarketState(marketOverrides), + settings: createSettingsState(starredMarketCoins), + }, + }); + +describe("useMarketCoin", () => { + beforeEach(() => { + jest.clearAllMocks(); + useParams.mockReturnValue({ currencyId: "bitcoin" }); + }); + + it("should return isStarred=true when currencyId is in starredMarketCoins", () => { + const { result } = renderMarketCoinHook({ starredMarketCoins: ["bitcoin"] }); + + expect(result.current.isStarred).toBe(true); + }); + + it("should return isStarred=false when currencyId is NOT in starredMarketCoins", () => { + const { result } = renderMarketCoinHook(); + + expect(result.current.isStarred).toBe(false); + }); + + it("should return isStarred=false when currencyId is undefined", () => { + useParams.mockReturnValue({}); + + const { result } = renderMarketCoinHook({ starredMarketCoins: ["bitcoin"] }); + + expect(result.current.isStarred).toBe(false); + }); + + it("should dispatch removeStarredMarketCoins when coin is starred", async () => { + const { result, store } = renderMarketCoinHook({ starredMarketCoins: ["bitcoin"] }); + + await waitFor(() => { + expect(result.current.currency).toBeDefined(); + }); + + await act(async () => { + result.current.toggleStar(); + }); + + expect(store.getState().settings.starredMarketCoins).not.toContain("bitcoin"); + }); + + it("should dispatch addStarredMarketCoins when coin is not starred", async () => { + const { result, store } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.currency).toBeDefined(); + }); + + await act(async () => { + result.current.toggleStar(); + }); + + expect(store.getState().settings.starredMarketCoins).toContain("bitcoin"); + }); + + it("should be no-op when currency data is unavailable", async () => { + server.use(http.get(MARKET_API, () => HttpResponse.json(null, { status: 404 }))); + + const { result, store } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.isLoadingCurrency).toBe(false); + }); + + act(() => { + result.current.toggleStar(); + }); + + expect(store.getState().settings.starredMarketCoins).toEqual([]); + }); + + it("should dispatch setMarketOptions with new range", async () => { + const { result, store } = renderMarketCoinHook(); + + await act(async () => { + result.current.changeRange("7d"); + }); + + expect(store.getState().market.marketParams.range).toBe("7d"); + }); + + it("should use supported currency when available", async () => { + const { result, store } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.supportedCounterCurrencies).toBeDefined(); + }); + + await act(async () => { + result.current.changeCounterCurrency("EUR"); + }); + + expect(store.getState().market.marketParams.counterCurrency).toBe("EUR"); + }); + + it("should fall back to usd when currency is not supported", async () => { + const { result, store } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.supportedCounterCurrencies).toBeDefined(); + }); + + await act(async () => { + result.current.changeCounterCurrency("XYZ"); + }); + + expect(store.getState().market.marketParams.counterCurrency).toBe("usd"); + }); + + it("should use getCurrencyColor when ledgerCurrency exists", async () => { + const { result } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.color).toBe("#FF9900"); + }); + + expect(getCurrencyColor).toHaveBeenCalledWith( + expect.objectContaining({ id: "bitcoin" }), + expect.any(String), + ); + }); + + it("should fall back to primary color when ledgerCurrency is undefined", async () => { + server.use(http.get(DADA_API, () => HttpResponse.json(EMPTY_DADA_RESPONSE))); + + const { result } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.isLoadingCurrency).toBe(false); + }); + + expect(result.current.color).toBe("#BBB0FF"); + }); + + it("should return dataChart and isLoadingDataChart from useCurrencyChartData", async () => { + const { result } = renderMarketCoinHook(); + + await waitFor(() => { + expect(result.current.isLoadingDataChart).toBe(false); + }); + + expect(result.current.dataChart).toEqual({ + "24h": [ + [1700000000000, 50000], + [1700003600000, 50100], + ], + }); + }); + + it("should return isLoadingCurrency while data is fetching", async () => { + const { result } = renderMarketCoinHook(); + + expect(result.current.isLoadingCurrency).toBe(true); + + await waitFor(() => { + expect(result.current.isLoadingCurrency).toBe(false); + }); + }); + + it("should pass through availability flags from useMarketActions", () => { + const { result } = renderMarketCoinHook(); + + expect(result.current.availableOnBuy).toBe(true); + expect(result.current.availableOnSwap).toBe(true); + expect(result.current.availableOnStake).toBe(false); + }); + + it("should return counterCurrency and range from Redux state", () => { + const { result } = renderMarketCoinHook({ + marketOverrides: { counterCurrency: "eur", range: "7d" }, + }); + + expect(result.current.counterCurrency).toBe("eur"); + expect(result.current.range).toBe("7d"); + }); +});