diff --git a/app/src/main/java/one/mixin/android/extension/IterableExtension.kt b/app/src/main/java/one/mixin/android/extension/IterableExtension.kt
index c46c8cc533..0659c4f0ee 100644
--- a/app/src/main/java/one/mixin/android/extension/IterableExtension.kt
+++ b/app/src/main/java/one/mixin/android/extension/IterableExtension.kt
@@ -8,3 +8,26 @@ suspend fun Iterable.pmap(f: suspend (A) -> B): List =
coroutineScope {
map { async { f(it) } }.awaitAll()
}
+
+fun mergeLocalAndRefreshed(
+ localMatches: List,
+ refreshedMatches: List,
+ keySelector: (T) -> K,
+): List {
+ val refreshedByKey = refreshedMatches.associateBy(keySelector)
+ val localKeys = LinkedHashSet()
+ val merged = ArrayList(localMatches.size + refreshedMatches.size)
+
+ localMatches.forEach { local ->
+ val key = keySelector(local)
+ localKeys += key
+ merged += refreshedByKey[key] ?: local
+ }
+ refreshedMatches.forEach { refreshed ->
+ if (keySelector(refreshed) !in localKeys) {
+ merged += refreshed
+ }
+ }
+
+ return merged
+}
diff --git a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt
index 217aea60ec..ad30490a59 100644
--- a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt
+++ b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt
@@ -1,6 +1,7 @@
package one.mixin.android.repository
import androidx.room.withTransaction
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import one.mixin.android.Constants
import one.mixin.android.api.response.perps.PerpsFavorite
@@ -12,6 +13,7 @@ import one.mixin.android.db.PerpsDatabase
import one.mixin.android.db.perps.PerpsFavoriteDao
import one.mixin.android.db.perps.PerpsMarketCategoryDao
import one.mixin.android.db.perps.PerpsMarketDao
+import one.mixin.android.extension.mergeLocalAndRefreshed
import one.mixin.android.extension.nowInUtc
import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI
import one.mixin.android.vo.market.MarketCategory
@@ -40,6 +42,13 @@ class PerpsMarketRepository
suspend fun searchMarkets(query: String): List = marketDao.searchMarkets(query.trim())
+ suspend fun searchMarketsOnlineFirst(query: String): List =
+ searchPerpsMarketsOnlineFirst(
+ query = query,
+ searchLocalMarkets = marketDao::searchMarkets,
+ syncOnlineMarkets = { syncAllMarkets() },
+ )
+
suspend fun getMarket(marketId: String): PerpsMarket? = marketDao.getMarket(marketId)
suspend fun syncAllMarkets(): List? {
@@ -167,7 +176,10 @@ class PerpsMarketRepository
response.data.orEmpty().map(PerpsMarket::withDefaults)
},
failureBlock = { true },
- exceptionBlock = { true },
+ exceptionBlock = { throwable ->
+ if (throwable is CancellationException) throw throwable
+ true
+ },
defaultErrorHandle = {},
defaultExceptionHandle = {},
requestSession = {
@@ -179,3 +191,24 @@ class PerpsMarketRepository
const val CATEGORY_FAVORITE = "favorite"
}
}
+
+internal suspend fun searchPerpsMarketsOnlineFirst(
+ query: String,
+ searchLocalMarkets: suspend (query: String) -> List,
+ syncOnlineMarkets: suspend () -> Unit,
+): List {
+ val normalizedQuery = query.trim()
+ if (normalizedQuery.isBlank()) return emptyList()
+
+ val localMatches = searchLocalMarkets(normalizedQuery)
+
+ return try {
+ syncOnlineMarkets()
+ val refreshedMatches = searchLocalMarkets(normalizedQuery)
+ mergeLocalAndRefreshed(localMatches, refreshedMatches, PerpsMarket::marketId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ localMatches
+ }
+}
diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt
index 47dc62c601..1f09eb5132 100644
--- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt
+++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt
@@ -61,11 +61,8 @@ class MarketSearchFragment : BaseFragment() {
}
}
- override fun onViewCreated(
- view: View,
- savedInstanceState: Bundle?,
- ) {
- super.onViewCreated(view, savedInstanceState)
+ override fun onStart() {
+ super.onStart()
marketSearchViewModel.loadRecentSearches(requireContext().defaultSharedPreferences)
}
diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt
index 7882c64c0d..485be3f533 100644
--- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt
+++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt
@@ -39,6 +39,22 @@ internal fun List.sortedForTrendingSearch(): List =
.thenBy { it.marketId },
)
+internal fun List.sortedForMarketSearch(
+ query: String,
+ symbol: (T) -> String,
+ name: (T) -> String,
+ volume: (T) -> String,
+): List {
+ val normalizedQuery = query.trim()
+ return sortedWith(
+ compareByDescending { symbol(it).equals(normalizedQuery, ignoreCase = true) }
+ .thenByDescending { name(it).equals(normalizedQuery, ignoreCase = true) }
+ .thenByDescending { volume(it).toBigDecimalOrNull() ?: BigDecimal.ZERO }
+ .thenBy { symbol(it).lowercase(Locale.ROOT) }
+ .thenBy { name(it).lowercase(Locale.ROOT) },
+ )
+}
+
internal data class MarketSearchUiState(
val query: String = "",
val selectedTab: MarketSearchTab = MarketSearchTab.CRYPTO,
diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt
index c1e901e622..9a05ea608b 100644
--- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt
+++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -113,6 +114,7 @@ internal fun MarketSearchPage(
text = stringResource(R.string.Recent),
color = MixinAppTheme.colors.textAssist,
fontSize = 14.sp,
+ modifier = Modifier.padding(vertical = 16.dp),
)
IconButton(
onClick = onClearRecentSearches,
@@ -130,7 +132,7 @@ internal fun MarketSearchPage(
FlowRow(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 20.dp, vertical = 8.dp),
+ .padding(start = 20.dp, end = 20.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
@@ -157,7 +159,7 @@ internal fun MarketSearchPage(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 20.dp, vertical = 12.dp),
+ .padding(start = 20.dp, top = 20.dp, end = 20.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
tabs.forEach { tab ->
@@ -417,10 +419,10 @@ private fun MarketRecentSearchChip(
verticalAlignment = Alignment.CenterVertically,
) {
MarketIcon(url = recentSearch.iconUrl.orEmpty(), size = 32.dp)
- Spacer(modifier = Modifier.width(6.dp))
+ Spacer(modifier = Modifier.width(4.dp))
Column(
modifier = Modifier.wrapContentWidth(),
- verticalArrangement = Arrangement.spacedBy(3.dp),
+ verticalArrangement = Arrangement.spacedBy((-2).dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -429,6 +431,9 @@ private fun MarketRecentSearchChip(
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
+ lineHeight = 18.sp,
+ style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
+ modifier = Modifier.widthIn(min = 34.dp),
)
if (recentSearch.type == RecentSearchType.PERPETUAL) {
Spacer(modifier = Modifier.width(3.dp))
@@ -449,11 +454,12 @@ private fun MarketRecentSearchChip(
Text(
text = formatSearchPercent(search.change),
color = searchChangeColor(search.change, quoteColorReversed),
- fontSize = 12.sp,
- lineHeight = 12.sp,
+ fontSize = 13.sp,
+ lineHeight = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
+ modifier = Modifier.widthIn(min = 34.dp),
)
}
}
@@ -523,7 +529,7 @@ private fun PerpetualMarketSearchRow(
title = market.displaySymbol.ifBlank { market.tokenSymbol },
badge = stringResource(R.string.Perp),
volume = formatSearchPerpetualVolume(market.volume),
- price = "$${market.last}",
+ price = formatPerpsMarketListPrice(market.last),
change = market.changePercentValue(),
quoteColorReversed = quoteColorReversed,
onClick = onClick,
diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt
index fa7490de13..d48867f6c7 100644
--- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt
+++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt
@@ -17,9 +17,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import one.mixin.android.api.response.perps.PerpsMarket
import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH
import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH
import one.mixin.android.extension.escapeSql
+import one.mixin.android.extension.mergeLocalAndRefreshed
import one.mixin.android.extension.putString
import one.mixin.android.extension.remove
import one.mixin.android.repository.PerpsMarketRepository
@@ -27,6 +30,7 @@ import one.mixin.android.repository.TokenRepository
import one.mixin.android.util.GsonHelper
import one.mixin.android.vo.RecentSearch
import one.mixin.android.vo.RecentSearchType
+import one.mixin.android.vo.market.Market
import one.mixin.android.vo.market.MarketCategory
import one.mixin.android.vo.market.MarketItem
import javax.inject.Inject
@@ -131,7 +135,6 @@ internal class MarketSearchViewModel
viewModelScope.launch(Dispatchers.IO) {
val searches = readRecentSearches(sp).addMarketRecentSearch(search)
sp.putString(PREF_MARKET_RECENT_SEARCH, GsonHelper.customGson.toJson(searches))
- publishRecentSearches(searches)
}
}
@@ -142,13 +145,27 @@ internal class MarketSearchViewModel
}
suspend fun findSpotMarket(coinId: String): MarketItem? =
- tokenRepository.findMarketItemByCoinId(coinId)
+ withContext(Dispatchers.IO) {
+ try {
+ tokenRepository.checkMarketById(coinId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ null
+ }
+ }
suspend fun findPerpetualMarket(marketId: String) =
perpsMarketRepository.getOrRefreshMarket(marketId)
private suspend fun publishRecentSearches(searches: List) {
- _recentSearches.value = searches.map { MarketRecentSearch(it) }
+ val previousSearches = _recentSearches.value
+ _recentSearches.value = searches.map { search ->
+ val change = previousSearches.firstOrNull {
+ it.search.type == search.type && it.search.primaryKey == search.primaryKey
+ }?.change
+ MarketRecentSearch(search, change)
+ }
val resolvedSearches = mutableListOf()
for (search in searches) {
resolvedSearches += resolveRecentSearch(search)
@@ -162,7 +179,7 @@ internal class MarketSearchViewModel
when (search.type) {
RecentSearchType.MARKET ->
search.primaryKey
- ?.let { tokenRepository.findMarketItemByCoinId(it) }
+ ?.let { findSpotMarket(it) }
?.priceChangePercentage24H
?.toBigDecimalOrNull()
RecentSearchType.PERPETUAL ->
@@ -205,15 +222,18 @@ internal class MarketSearchViewModel
private suspend fun searchSpotMarkets(query: String): List =
try {
- val escapedQuery = query.escapeSql()
- var markets = tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal())
- if (markets.isEmpty()) {
- tokenRepository.searchMarket(query)
- markets = tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal())
- }
- markets.map { market ->
- tokenRepository.findMarketItemByCoinId(market.coinId) ?: MarketItem.fromMarket(market)
- }
+ searchSpotMarketsOnlineFirst(
+ query = query,
+ searchLocalMarkets = { escapedQuery ->
+ tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal())
+ },
+ refreshOnlineMarkets = { normalizedQuery ->
+ tokenRepository.searchMarket(normalizedQuery)
+ },
+ resolveMarketItem = { market ->
+ tokenRepository.findMarketItemByCoinId(market.coinId) ?: MarketItem.fromMarket(market)
+ },
+ )
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
@@ -222,11 +242,48 @@ internal class MarketSearchViewModel
private suspend fun searchPerpetualMarkets(query: String) =
try {
- initialPerpetualSyncJob.join()
- perpsMarketRepository.searchMarkets(query)
+ perpsMarketRepository.searchMarketsOnlineFirst(query).sortedForMarketSearch(
+ query = query,
+ symbol = PerpsMarket::tokenSymbol,
+ name = PerpsMarket::displaySymbol,
+ volume = PerpsMarket::volume,
+ )
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
- }
+}
+
+internal suspend fun searchSpotMarketsOnlineFirst(
+ query: String,
+ searchLocalMarkets: suspend (escapedQuery: String) -> List,
+ refreshOnlineMarkets: suspend (query: String) -> Unit,
+ resolveMarketItem: suspend (Market) -> MarketItem,
+): List {
+ val normalizedQuery = query.trim()
+ if (normalizedQuery.isBlank()) return emptyList()
+
+ val escapedQuery = normalizedQuery.escapeSql()
+ val localMatches = searchLocalMarkets(escapedQuery)
+
+ val markets =
+ try {
+ refreshOnlineMarkets(normalizedQuery)
+ val refreshedMatches = searchLocalMarkets(escapedQuery)
+ mergeLocalAndRefreshed(localMatches, refreshedMatches, Market::coinId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ localMatches
+ }
+
+ return markets
+ .map { market -> resolveMarketItem(market) }
+ .sortedForMarketSearch(
+ query = normalizedQuery,
+ symbol = MarketItem::symbol,
+ name = MarketItem::name,
+ volume = MarketItem::totalVolume,
+ )
+}
diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt
index 5447e026f4..3428d34a71 100644
--- a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt
+++ b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt
@@ -98,6 +98,39 @@ class PerpsMarketDaoTest {
assertEquals(listOf("btc-usdt", "eth-btc"), result.map(PerpsMarket::marketId))
}
+ @Test
+ fun upsertedRefreshedMarketsBecomeSearchableWithoutClearingDatabase() =
+ runBlocking {
+ database.perpsMarketDao().upsertList(
+ listOf(
+ market("eth-usdt", volume = "50", score = 0).copy(
+ displaySymbol = "ETHUSDT",
+ tokenSymbol = "ETH",
+ quoteSymbol = "USDT",
+ ),
+ ),
+ )
+
+ database.perpsMarketDao().upsertList(
+ listOf(
+ market("btc-usdt", volume = "100", score = 0).copy(
+ displaySymbol = "BTCUSDT",
+ tokenSymbol = "BTC",
+ quoteSymbol = "USDT",
+ ),
+ market("eth-usdt", volume = "60", score = 0).copy(
+ displaySymbol = "ETHUSDT",
+ tokenSymbol = "ETH",
+ quoteSymbol = "USDT",
+ ),
+ ),
+ )
+
+ val result = database.perpsMarketDao().searchMarkets("btc")
+
+ assertEquals(listOf("btc-usdt"), result.map(PerpsMarket::marketId))
+ }
+
@Test
fun favoriteMarketsOrderByNewestAddition() =
runBlocking {
diff --git a/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt b/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt
new file mode 100644
index 0000000000..8965dd49f5
--- /dev/null
+++ b/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt
@@ -0,0 +1,144 @@
+package one.mixin.android.repository
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+import one.mixin.android.api.response.perps.PerpsMarket
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PerpsMarketRepositoryTest {
+ @Test
+ fun perpsSearch_callsSyncEvenWhenLocalMatchesAreNonEmpty() =
+ runBlocking {
+ val local = listOf(market("btc-usdt"))
+ var syncCalls = 0
+
+ searchPerpsMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = { local },
+ syncOnlineMarkets = { syncCalls += 1 },
+ )
+
+ assertEquals(1, syncCalls)
+ }
+
+ @Test
+ fun perpsSearch_appendsRemoteOnlyMatchesAfterSync() =
+ runBlocking {
+ val initialLocal = listOf(market("btc-usdt"))
+ val refreshedLocal = listOf(market("btc-usdt"), market("btc-usdc"))
+ var searchCalls = 0
+
+ val result =
+ searchPerpsMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = {
+ searchCalls += 1
+ if (searchCalls == 1) initialLocal else refreshedLocal
+ },
+ syncOnlineMarkets = {},
+ )
+
+ assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(PerpsMarket::marketId))
+ }
+
+ @Test
+ fun perpsSearch_preservesLocalOrderButUsesRefreshedOverlappingMatchesByMarketId() =
+ runBlocking {
+ val staleLocal = listOf(market("eth-usdt").copy(last = "1"), market("btc-usdt").copy(last = "1"))
+ val refreshedLocal =
+ listOf(
+ market("eth-usdt").copy(last = "2"),
+ market("btc-usdt").copy(last = "3"),
+ market("sol-usdt"),
+ )
+ var searchCalls = 0
+
+ val result =
+ searchPerpsMarketsOnlineFirst(
+ query = "usdt",
+ searchLocalMarkets = {
+ searchCalls += 1
+ if (searchCalls == 1) staleLocal else refreshedLocal
+ },
+ syncOnlineMarkets = {},
+ )
+
+ assertEquals(listOf("eth-usdt", "btc-usdt", "sol-usdt"), result.map(PerpsMarket::marketId))
+ assertEquals("2", result[0].last)
+ assertEquals("3", result[1].last)
+ }
+
+ @Test
+ fun perpsSearch_returnsInitialLocalMatchesWhenSyncFails() =
+ runBlocking {
+ val local = listOf(market("btc-usdt"))
+
+ val result =
+ searchPerpsMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = { local },
+ syncOnlineMarkets = { error("offline") },
+ )
+
+ assertEquals(listOf("btc-usdt"), result.map(PerpsMarket::marketId))
+ }
+
+ @Test(expected = CancellationException::class)
+ fun perpsSearch_propagatesCancellation() {
+ runBlocking {
+ searchPerpsMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = { listOf(market("btc-usdt")) },
+ syncOnlineMarkets = { throw CancellationException("cancelled") },
+ )
+ }
+ }
+
+ @Test
+ fun perpsSearch_blankQuerySkipsLocalAndSyncCalls() =
+ runBlocking {
+ var localCalls = 0
+ var syncCalls = 0
+
+ val result =
+ searchPerpsMarketsOnlineFirst(
+ query = " ",
+ searchLocalMarkets = {
+ localCalls += 1
+ emptyList()
+ },
+ syncOnlineMarkets = { syncCalls += 1 },
+ )
+
+ assertTrue(result.isEmpty())
+ assertEquals(0, localCalls)
+ assertEquals(0, syncCalls)
+ }
+
+ private fun market(marketId: String) =
+ PerpsMarket(
+ marketId = marketId,
+ displaySymbol = marketId,
+ tokenSymbol = marketId,
+ quoteSymbol = "USD",
+ markPrice = "1",
+ leverage = 10,
+ iconUrl = "",
+ fundingRate = "0",
+ minAmount = "0",
+ maxAmount = "0",
+ last = "1",
+ volume = "1",
+ tradeVolumeScore1D = 0,
+ high = "1",
+ low = "1",
+ open = "1",
+ change = "0",
+ bidPrice = "1",
+ askPrice = "1",
+ createdAt = "",
+ updatedAt = "",
+ )
+}
diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt
index 220e4eb684..2e57e702f7 100644
--- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt
+++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt
@@ -47,6 +47,30 @@ class MarketSearchModelsTest {
)
}
+ @Test
+ fun marketSearchRanksExactFieldsThenVolumeAndAlphabetically() {
+ val markets =
+ listOf(
+ market("volume", volume = "100", tokenSymbol = "BTC-X"),
+ market("name", volume = "1", tokenSymbol = "AAA", displaySymbol = "BTC"),
+ market("symbol", volume = "0", tokenSymbol = "BTC"),
+ market("alpha-b", volume = "10", tokenSymbol = "ETH"),
+ market("alpha-a", volume = "10", tokenSymbol = "AAA"),
+ )
+
+ assertEquals(
+ listOf("symbol", "name", "volume", "alpha-a", "alpha-b"),
+ markets
+ .sortedForMarketSearch(
+ query = "btc",
+ symbol = PerpsMarket::tokenSymbol,
+ name = PerpsMarket::displaySymbol,
+ volume = PerpsMarket::volume,
+ )
+ .map(PerpsMarket::marketId),
+ )
+ }
+
@Test
fun recentSearchesKeepOnlySpotAndPerpetualMarkets() {
val searches =
@@ -88,11 +112,13 @@ class MarketSearchModelsTest {
private fun market(
marketId: String,
volume: String,
- score: Int,
+ score: Int = 0,
+ displaySymbol: String = marketId,
+ tokenSymbol: String = marketId,
) = PerpsMarket(
marketId = marketId,
- displaySymbol = marketId,
- tokenSymbol = marketId,
+ displaySymbol = displaySymbol,
+ tokenSymbol = tokenSymbol,
quoteSymbol = "USD",
markPrice = "1",
leverage = 10,
diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt
new file mode 100644
index 0000000000..e7dd629732
--- /dev/null
+++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt
@@ -0,0 +1,202 @@
+package one.mixin.android.ui.home.web3.market
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+import one.mixin.android.vo.market.Market
+import one.mixin.android.vo.market.MarketItem
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MarketSearchViewModelSearchTest {
+ @Test
+ fun spotSearch_callsRemoteEvenWhenLocalMatchesAreNonEmpty() =
+ runBlocking {
+ val local = listOf(market("btc-local"))
+ var remoteCalls = 0
+
+ searchSpotMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = { local },
+ refreshOnlineMarkets = { remoteCalls++ },
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+
+ assertEquals(1, remoteCalls)
+ }
+
+ @Test
+ fun spotSearch_appendsRemoteOnlyMatchesAfterRefresh() =
+ runBlocking {
+ val initialLocal = listOf(market("btc-usdt"))
+ val refreshedLocal = listOf(market("btc-usdt"), market("btc-usdc"))
+ var searchCalls = 0
+
+ val result =
+ searchSpotMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = {
+ searchCalls += 1
+ if (searchCalls == 1) initialLocal else refreshedLocal
+ },
+ refreshOnlineMarkets = {},
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+
+ assertEquals(listOf("btc-usdc", "btc-usdt"), result.map(MarketItem::coinId))
+ }
+
+ @Test
+ fun spotSearch_sortsByExactSymbolThenNameThenVolume() =
+ runBlocking {
+ val initialLocal =
+ listOf(
+ market("volume-low", symbol = "BTC-X", totalVolume = "1"),
+ market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"),
+ )
+ val refreshedLocal =
+ listOf(
+ market("volume-low", symbol = "BTC-X", totalVolume = "1"),
+ market("volume-high", symbol = "BTC-Y", totalVolume = "100"),
+ market("symbol-exact", symbol = "BTC", totalVolume = "0"),
+ market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"),
+ )
+ var searchCalls = 0
+
+ val result =
+ searchSpotMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = {
+ searchCalls += 1
+ if (searchCalls == 1) initialLocal else refreshedLocal
+ },
+ refreshOnlineMarkets = {},
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+
+ assertEquals(
+ listOf("symbol-exact", "name-exact", "volume-high", "volume-low"),
+ result.map(MarketItem::coinId),
+ )
+ }
+
+ @Test
+ fun spotSearch_preservesLocalOrderButUsesRefreshedOverlappingMatches() =
+ runBlocking {
+ val staleLocal = listOf(market("btc").copy(name = "Local BTC"), market("eth").copy(name = "Local ETH"))
+ val refreshedLocal =
+ listOf(
+ market("eth").copy(name = "Remote ETH"),
+ market("btc").copy(name = "Remote BTC"),
+ market("btc-cash"),
+ )
+ var searchCalls = 0
+
+ val result =
+ searchSpotMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = {
+ searchCalls += 1
+ if (searchCalls == 1) staleLocal else refreshedLocal
+ },
+ refreshOnlineMarkets = {},
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+
+ assertEquals(listOf("btc", "btc-cash", "eth"), result.map(MarketItem::coinId))
+ assertEquals("Remote BTC", result.first().name)
+ assertEquals("Remote ETH", result.last().name)
+ }
+
+ @Test
+ fun spotSearch_returnsLocalMatchesWhenRemoteRefreshFails() =
+ runBlocking {
+ val local = listOf(market("btc"))
+
+ val result =
+ searchSpotMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = { local },
+ refreshOnlineMarkets = { error("offline") },
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+
+ assertEquals(listOf("btc"), result.map(MarketItem::coinId))
+ }
+
+ @Test(expected = CancellationException::class)
+ fun spotSearch_propagatesCancellation() {
+ runBlocking {
+ searchSpotMarketsOnlineFirst(
+ query = "btc",
+ searchLocalMarkets = { listOf(market("btc")) },
+ refreshOnlineMarkets = { throw CancellationException("cancelled") },
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+ }
+ }
+
+ @Test
+ fun spotSearch_blankQuerySkipsLocalAndRemoteSearch() =
+ runBlocking {
+ var localCalls = 0
+ var remoteCalls = 0
+
+ val result =
+ searchSpotMarketsOnlineFirst(
+ query = " ",
+ searchLocalMarkets = {
+ localCalls += 1
+ emptyList()
+ },
+ refreshOnlineMarkets = { remoteCalls += 1 },
+ resolveMarketItem = { MarketItem.fromMarket(it) },
+ )
+
+ assertTrue(result.isEmpty())
+ assertEquals(0, localCalls)
+ assertEquals(0, remoteCalls)
+ }
+
+ private fun market(
+ coinId: String,
+ marketCap: String = "1",
+ name: String = coinId.uppercase(),
+ symbol: String = coinId.uppercase(),
+ totalVolume: String = "1",
+ ) =
+ Market(
+ coinId = coinId,
+ name = name,
+ symbol = symbol,
+ iconUrl = "",
+ currentPrice = "1",
+ marketCap = marketCap,
+ marketCapRank = "1",
+ totalVolume = totalVolume,
+ high24h = "1",
+ low24h = "1",
+ priceChange24h = "0",
+ priceChangePercentage1H = "0",
+ priceChangePercentage24H = "0",
+ priceChangePercentage7D = "0",
+ priceChangePercentage30D = "0",
+ marketCapChange24h = "0",
+ marketCapChangePercentage24h = "0",
+ circulatingSupply = "1",
+ totalSupply = "1",
+ maxSupply = "1",
+ ath = "1",
+ athChangePercentage = "0",
+ athDate = "",
+ atl = "1",
+ atlChangePercentage = "0",
+ atlDate = "",
+ assetIds = emptyList(),
+ sparklineIn7d = "",
+ sparklineIn24h = "",
+ updatedAt = "",
+ descriptions = null,
+ perpsMarketId = null,
+ )
+}