Skip to content

Commit c3ba7cb

Browse files
committed
chore: minor changes
1 parent abe15f9 commit c3ba7cb

4 files changed

Lines changed: 352 additions & 10 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* usePerpsFeed — unit tests
3+
*
4+
* Focuses on the sorting/ordering logic that lives inside the useMemo:
5+
* 1. No-query path: items sorted by the variant's comparator.
6+
* 2. Query path (non-macro): Fuse.js relevance order is preserved.
7+
* 3. Query path (macro): sorted by volume even when a query is present.
8+
* 4. defaultSortOptionId matches PERPS_VARIANT_SORT_OPTION for each variant.
9+
*/
10+
11+
import { renderHook } from '@testing-library/react-hooks';
12+
import type { PerpsMarketData } from '@metamask/perps-controller';
13+
import { usePerpsFeed, PERPS_VARIANT_SORT_OPTION } from './usePerpsFeed';
14+
15+
// ---------------------------------------------------------------------------
16+
// Core dependency mocks
17+
// ---------------------------------------------------------------------------
18+
19+
jest.mock('react-redux', () => ({
20+
useSelector: jest.fn(() => []),
21+
}));
22+
23+
const mockMarkets: PerpsMarketData[] = [];
24+
const mockRefetch = jest.fn();
25+
26+
jest.mock('../../../../UI/Perps/hooks', () => ({
27+
usePerpsMarkets: jest.fn(() => ({
28+
markets: mockMarkets,
29+
isLoading: false,
30+
refresh: mockRefetch,
31+
isRefreshing: false,
32+
})),
33+
}));
34+
35+
jest.mock('../../../../UI/Perps/providers/PerpsConnectionProvider', () => ({
36+
PerpsConnectionContext: { _currentValue: null },
37+
}));
38+
39+
jest.mock('../../../../UI/Perps/selectors/perpsController', () => ({
40+
selectPerpsWatchlistMarkets: jest.fn(),
41+
}));
42+
43+
jest.mock(
44+
'../../../Homepage/Sections/Perpetuals/hooks/useHomepageSparklines',
45+
() => ({
46+
useHomepageSparklines: jest.fn(() => ({ sparklines: {} })),
47+
}),
48+
);
49+
50+
jest.mock('../../hooks/useFeedRefresh', () => ({
51+
useFeedRefresh: jest.fn(),
52+
}));
53+
54+
// ---------------------------------------------------------------------------
55+
// fuseSearch mock — controllable so we can verify order is preserved
56+
// ---------------------------------------------------------------------------
57+
58+
const mockFuseSearch = jest.fn();
59+
jest.mock('../search-utils', () => ({
60+
fuseSearch: (...args: unknown[]) => mockFuseSearch(...args),
61+
PERPS_FUSE_OPTIONS: {},
62+
}));
63+
64+
jest.mock('@metamask/perps-controller', () => ({
65+
filterMarketsByQuery: jest.fn((items: unknown[]) => items),
66+
}));
67+
68+
// ---------------------------------------------------------------------------
69+
// Helpers
70+
// ---------------------------------------------------------------------------
71+
72+
import { usePerpsMarkets } from '../../../../UI/Perps/hooks';
73+
74+
const makeMarket = (
75+
symbol: string,
76+
change24hPercent: string,
77+
volumeNumber: number,
78+
): PerpsMarketData =>
79+
({
80+
symbol,
81+
name: symbol,
82+
change24hPercent,
83+
volumeNumber,
84+
marketType: 'equity',
85+
isHip3: false,
86+
}) as unknown as PerpsMarketData;
87+
88+
const renderFeed = (options: Parameters<typeof usePerpsFeed>[0] = {}) =>
89+
renderHook(() => usePerpsFeed(options));
90+
91+
// ---------------------------------------------------------------------------
92+
// Tests
93+
// ---------------------------------------------------------------------------
94+
95+
describe('usePerpsFeed', () => {
96+
beforeEach(() => {
97+
jest.clearAllMocks();
98+
// Default: fuseSearch returns items as-is
99+
mockFuseSearch.mockImplementation((items: unknown[]) => items);
100+
});
101+
102+
describe('no-query path', () => {
103+
it('sorts all/crypto/rwa variants by 24h price change descending', () => {
104+
const markets = [
105+
makeMarket('LOW', '1', 100),
106+
makeMarket('HIGH', '5', 50),
107+
makeMarket('MID', '3', 75),
108+
];
109+
(usePerpsMarkets as jest.Mock).mockReturnValue({
110+
markets,
111+
isLoading: false,
112+
refresh: mockRefetch,
113+
isRefreshing: false,
114+
});
115+
116+
for (const variant of ['all', 'crypto', 'rwa'] as const) {
117+
const { result } = renderFeed({ variant });
118+
const symbols = result.current.data.map((d) => d.market.symbol);
119+
expect(symbols).toEqual(['HIGH', 'MID', 'LOW']);
120+
}
121+
});
122+
123+
it('sorts macro variant by volume descending', () => {
124+
const markets = [
125+
makeMarket('LOW_VOL', '5', 10),
126+
makeMarket('HIGH_VOL', '1', 200),
127+
makeMarket('MID_VOL', '3', 100),
128+
].map((m) => ({ ...m, marketType: 'equity' as const }));
129+
130+
(usePerpsMarkets as jest.Mock).mockReturnValue({
131+
markets,
132+
isLoading: false,
133+
refresh: mockRefetch,
134+
isRefreshing: false,
135+
});
136+
137+
const { result } = renderFeed({ variant: 'macro' });
138+
const symbols = result.current.data.map((d) => d.market.symbol);
139+
expect(symbols).toEqual(['HIGH_VOL', 'MID_VOL', 'LOW_VOL']);
140+
});
141+
});
142+
143+
describe('query path', () => {
144+
it('preserves Fuse.js relevance order for non-macro variants', () => {
145+
const markets = [
146+
makeMarket('BTC', '1', 100),
147+
makeMarket('ETH', '5', 50),
148+
makeMarket('SOL', '3', 75),
149+
];
150+
(usePerpsMarkets as jest.Mock).mockReturnValue({
151+
markets,
152+
isLoading: false,
153+
refresh: mockRefetch,
154+
isRefreshing: false,
155+
});
156+
157+
// Fuse returns a specific relevance order (SOL first, then ETH, then BTC)
158+
const fuseRelevanceOrder = [
159+
makeMarket('SOL', '3', 75),
160+
makeMarket('ETH', '5', 50),
161+
makeMarket('BTC', '1', 100),
162+
];
163+
mockFuseSearch.mockReturnValue(fuseRelevanceOrder);
164+
165+
for (const variant of ['all', 'crypto', 'rwa'] as const) {
166+
const { result } = renderFeed({ variant, query: 'S' });
167+
const symbols = result.current.data.map((d) => d.market.symbol);
168+
// Must match fuse order, NOT sorted by price change (which would be ETH→SOL→BTC)
169+
expect(symbols).toEqual(['SOL', 'ETH', 'BTC']);
170+
}
171+
});
172+
173+
it('sorts macro fuse results by volume, overriding relevance order', () => {
174+
const markets = [
175+
makeMarket('AAPL', '1', 10),
176+
makeMarket('MSFT', '5', 200),
177+
makeMarket('NVDA', '3', 100),
178+
].map((m) => ({ ...m, marketType: 'equity' as const }));
179+
180+
(usePerpsMarkets as jest.Mock).mockReturnValue({
181+
markets,
182+
isLoading: false,
183+
refresh: mockRefetch,
184+
isRefreshing: false,
185+
});
186+
187+
// Fuse returns relevance order: AAPL first
188+
mockFuseSearch.mockReturnValue([
189+
markets[0], // AAPL — low volume, but top relevance match
190+
markets[1], // MSFT
191+
markets[2], // NVDA
192+
]);
193+
194+
const { result } = renderFeed({ variant: 'macro', query: 'A' });
195+
const symbols = result.current.data.map((d) => d.market.symbol);
196+
// Must be sorted by volume desc, NOT fuse order
197+
expect(symbols).toEqual(['MSFT', 'NVDA', 'AAPL']);
198+
});
199+
});
200+
201+
describe('defaultSortOptionId', () => {
202+
it.each([
203+
['all', 'priceChange'],
204+
['crypto', 'priceChange'],
205+
['rwa', 'priceChange'],
206+
['macro', 'volume'],
207+
] as const)(
208+
'returns "%s" for variant "%s"',
209+
(variant, expectedSortOptionId) => {
210+
(usePerpsMarkets as jest.Mock).mockReturnValue({
211+
markets: [],
212+
isLoading: false,
213+
refresh: mockRefetch,
214+
isRefreshing: false,
215+
});
216+
217+
const { result } = renderFeed({ variant });
218+
expect(result.current.defaultSortOptionId).toBe(expectedSortOptionId);
219+
// Also verify it matches the canonical map
220+
expect(result.current.defaultSortOptionId).toBe(
221+
PERPS_VARIANT_SORT_OPTION[variant],
222+
);
223+
},
224+
);
225+
});
226+
});

app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ export const usePerpsFeed = ({
134134
}
135135
const queryFiltered = filterMarketsByQuery(subset, query);
136136
const fused = fuseSearch(queryFiltered, query, PERPS_FUSE_OPTIONS);
137-
return [...fused].sort(sortFn);
137+
// Preserve Fuse.js relevance ordering for variants that sort by price change
138+
// (the relevance signal is more useful than a metric sort during search).
139+
// Macro sorts by volume even in search results, consistent with its feed order.
140+
return variant === 'macro' ? [...fused].sort(sortFn) : fused;
138141
}, [connectionContext?.error, markets, variant, query]);
139142

140143
// Only visible carousel tiles need candle sparklines; each symbol is a stream
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { renderHook } from '@testing-library/react-native';
2+
import type { TrendingAsset } from '@metamask/assets-controllers';
3+
import { useRwaTokens } from '../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens';
4+
import { useStocksFeed } from './useStocksFeed';
5+
6+
jest.mock('../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({
7+
useRwaTokens: jest.fn(),
8+
}));
9+
10+
const mockUseRwaTokens = jest.mocked(useRwaTokens);
11+
const mockRefetch = jest.fn();
12+
13+
const makeAsset = (assetId: string, symbol: string): TrendingAsset =>
14+
({
15+
assetId,
16+
symbol,
17+
name: symbol,
18+
}) as unknown as TrendingAsset;
19+
20+
const ETH_OUSG = makeAsset('eip155:1/erc20:0xaaa', 'OUSG');
21+
const ETH_BUIDL = makeAsset('eip155:1/erc20:0xbbb', 'BUIDL');
22+
const BNB_OUSG = makeAsset('eip155:56/erc20:0xccc', 'bOUSG');
23+
24+
const ALL_RWA_ASSETS = [ETH_OUSG, ETH_BUIDL, BNB_OUSG];
25+
26+
const arrangeRwaTokens = (assets = ALL_RWA_ASSETS) => {
27+
mockUseRwaTokens.mockReturnValue({
28+
data: assets,
29+
isLoading: false,
30+
refetch: mockRefetch,
31+
});
32+
};
33+
34+
describe('useStocksFeed', () => {
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
arrangeRwaTokens();
38+
});
39+
40+
describe('no-query path (tab sections)', () => {
41+
it('filters to Ethereum-only assets', () => {
42+
const { result } = renderHook(() => useStocksFeed());
43+
const symbols = result.current.data.map((d) => d.symbol);
44+
expect(symbols).toEqual(['OUSG', 'BUIDL']);
45+
expect(symbols).not.toContain('bOUSG');
46+
});
47+
48+
it('passes undefined searchQuery to useRwaTokens', () => {
49+
renderHook(() => useStocksFeed());
50+
expect(mockUseRwaTokens).toHaveBeenCalledWith(
51+
expect.objectContaining({ searchQuery: undefined }),
52+
);
53+
});
54+
});
55+
56+
describe('query path (omni-search)', () => {
57+
it('includes tokens from all RWA chains, not just Ethereum', () => {
58+
const { result } = renderHook(() => useStocksFeed({ query: 'OUSG' }));
59+
const symbols = result.current.data.map((d) => d.symbol);
60+
expect(symbols).toContain('OUSG');
61+
expect(symbols).toContain('bOUSG');
62+
});
63+
64+
it('does not filter out BNB tokens when a query is present', () => {
65+
const { result } = renderHook(() => useStocksFeed({ query: 'token' }));
66+
expect(result.current.data).toHaveLength(ALL_RWA_ASSETS.length);
67+
});
68+
69+
it('passes the query through to useRwaTokens as searchQuery', () => {
70+
renderHook(() => useStocksFeed({ query: 'OUSG' }));
71+
expect(mockUseRwaTokens).toHaveBeenCalledWith(
72+
expect.objectContaining({ searchQuery: 'OUSG' }),
73+
);
74+
});
75+
76+
it('treats a whitespace-only query the same as no query (Ethereum-only)', () => {
77+
const { result } = renderHook(() => useStocksFeed({ query: ' ' }));
78+
const symbols = result.current.data.map((d) => d.symbol);
79+
expect(symbols).toEqual(['OUSG', 'BUIDL']);
80+
expect(symbols).not.toContain('bOUSG');
81+
});
82+
});
83+
84+
describe('loading and refetch passthrough', () => {
85+
it('forwards isLoading from useRwaTokens', () => {
86+
mockUseRwaTokens.mockReturnValue({
87+
data: [],
88+
isLoading: true,
89+
refetch: mockRefetch,
90+
});
91+
const { result } = renderHook(() => useStocksFeed());
92+
expect(result.current.isLoading).toBe(true);
93+
});
94+
95+
it('forwards refetch from useRwaTokens', () => {
96+
const { result } = renderHook(() => useStocksFeed());
97+
expect(result.current.refetch).toBe(mockRefetch);
98+
});
99+
});
100+
});

app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,18 @@ export interface UseStocksFeedResult {
1717
refetch: () => Promise<void>;
1818
}
1919

20-
/** Tokenized stocks (RWAs). Only Ethereum mainnet tokens are shown in the section. */
20+
/**
21+
* Tokenized stocks (RWAs) feed.
22+
*
23+
* Tab sections (no query): only Ethereum mainnet tokens are shown, matching
24+
* the design intent of the RWAs/Now tab.
25+
*
26+
* Search (query present): all chains in RWA_CHAIN_IDS are included so users
27+
* can find stocks across Ethereum and BNB.
28+
*
29+
* Chain filtering is done client-side (not in the request) to share the same
30+
* server-side cache across all surfaces.
31+
*/
2132
export const useStocksFeed = ({
2233
query,
2334
refresh,
@@ -26,15 +37,17 @@ export const useStocksFeed = ({
2637
searchQuery: query,
2738
});
2839

29-
// Keep mainnet filtering here (not in the request) so all surfaces share the same
30-
// RWA cache (server-side); chain-specific params would split the cache and diverge from the main feed.
31-
const ethereumData = useMemo(
32-
() =>
33-
data.filter((asset) => asset.assetId.startsWith(ETHEREUM_CAIP_CHAIN_ID)),
34-
[data],
35-
);
40+
const filteredData = useMemo(() => {
41+
// During search, surface tokens from all supported RWA chains so the user
42+
// can find any matching stock regardless of chain.
43+
if (query?.trim()) return data;
44+
// Tab sections only show Ethereum mainnet tokens.
45+
return data.filter((asset) =>
46+
asset.assetId.startsWith(ETHEREUM_CAIP_CHAIN_ID),
47+
);
48+
}, [data, query]);
3649

3750
useFeedRefresh(refresh, refetch);
3851

39-
return { data: ethereumData, isLoading, refetch };
52+
return { data: filteredData, isLoading, refetch };
4053
};

0 commit comments

Comments
 (0)