Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-11 17:51:23 +04:00
parent f72c0f448e
commit 0a44bcfc70
4 changed files with 256 additions and 1 deletions

View file

@ -69,4 +69,5 @@ dependencies {
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -13,6 +13,7 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateCo
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -37,6 +38,7 @@ internal class TxHistoryListManager(
) {
private val jobHolder = JobHolder()
private val autoLoadMoreJobHolder = JobHolder()
private val actionsFlow: MutableSharedFlow<TxHistoryBatchAction> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@ -65,6 +67,12 @@ internal class TxHistoryListManager(
batchSize = 50,
)
batchFlow.state
.onEach { batchState -> autoLoadMoreUntilScrollable(batchState) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(autoLoadMoreJobHolder)
if (designFeatureToggles.isRedesignEnabled) {
var previousLookup: TxHistoryLookupContext? = null
combine(batchFlow.state, lookupDataFlow) { batchState, lookup -> batchState to lookup }
@ -146,4 +154,19 @@ internal class TxHistoryListManager(
)
}
}
private suspend fun autoLoadMoreUntilScrollable(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>) {
val status = batchState.status as? PaginationStatus.Paginating ?: return
val lastResult = status.lastResult as? BatchFetchResult.Success ?: return
val loadedItemsCount = batchState.data.sumOf { batch -> batch.data.items.size }
val shouldLoadMore = loadedItemsCount < AUTO_LOAD_MORE_TARGET_COUNT || lastResult.empty
if (shouldLoadMore) {
loadMore(userWalletId, currency)
}
}
private companion object {
/** Number of loaded items considered enough to make the list scrollable. */
const val AUTO_LOAD_MORE_TARGET_COUNT = 20
}
}

View file

@ -0,0 +1,231 @@
package com.tangem.features.txhistory.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
import com.tangem.domain.txhistory.models.Page
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.PaginationStatus
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Verifies the auto-load behavior for Solana-style histories, where a fetched page is paginated over RAW
* transactions and then filtered down to a single token, so a page can yield few or zero displayable items.
* The manager must keep requesting the next page until the list is long enough to be scrolled or pagination
* ends instead of stopping on the first page that adds no items.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryListManagerTest {
private val userWalletId = UserWalletId(stringValue = "01")
private val currency = mockk<CryptoCurrency>(relaxed = true)
@Test
fun `GIVEN pages that are empty for the token WHEN loading THEN auto-loads through them until the end`() =
runTest {
// page 0: 2 items, then two empty-for-token pages, then 3 items on the last page → 5 items total.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 2, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 3, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// first fetch + 3 auto-loaded next pages = 4
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(5)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
}
}
@Test
fun `GIVEN many small non-final pages WHEN loading THEN stops once the list is long enough to scroll`() =
runTest {
// every page returns 7 items and is never the last page.
val fetcher = ScriptedFetcher { page(itemCount = 7, isLast = false) }
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// 7 -> 14 -> 21: stops after crossing AUTO_LOAD_MORE_TARGET_COUNT (20), does not keep loading.
assertThat(fetcher.fetchCount).isEqualTo(3)
assertThat(repo.loadedItemsCount()).isEqualTo(21)
assertThat(repo.status()).isInstanceOf(PaginationStatus.Paginating::class.java)
}
}
@Test
fun `GIVEN a full first page WHEN loading THEN does not auto-load more`() = runTest {
val fetcher = ScriptedFetcher { page(itemCount = 25, isLast = false) }
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// first page already exceeds the target → no auto-load, behaves like a normal scroll-driven list.
assertThat(fetcher.fetchCount).isEqualTo(1)
assertThat(repo.loadedItemsCount()).isEqualTo(25)
}
}
@Test
fun `GIVEN a gap of empty pages mid-history WHEN scrolled to the end THEN auto-loads through the gap`() =
runTest {
// A full first page (no auto-load), then two empty-for-token pages (a gap of other-token
// activity), then one final item. Mirrors a busy account where a token has a long activity gap.
val fetcher = ScriptedFetcher { call ->
when (call) {
0 -> page(itemCount = 25, isLast = false)
1 -> page(itemCount = 0, isLast = false)
2 -> page(itemCount = 0, isLast = false)
else -> page(itemCount = 1, isLast = true)
}
}
val repo = fakeRepository(fetcher)
val manager = createManager(repo)
withLoadedManager(manager) {
// full first page → no auto-load yet, the list is scrollable.
assertThat(fetcher.fetchCount).isEqualTo(1)
assertThat(repo.loadedItemsCount()).isEqualTo(25)
// user scrolls to the bottom → one loadMore; the empty gap must be auto-bridged to the end,
// otherwise the list dead-ends and the final transaction is never reached.
manager.loadMore(userWalletId, currency)
advanceUntilIdle()
assertThat(fetcher.fetchCount).isEqualTo(4)
assertThat(repo.loadedItemsCount()).isEqualTo(26)
assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java)
}
}
private suspend fun TestScope.withLoadedManager(
manager: TxHistoryListManager,
assertions: suspend TestScope.() -> Unit,
) {
// init() collects forever, so run it in a child coroutine and cancel it once assertions are done.
// Cancellation resets the source state, so assertions must run before it.
val initJob = launch { manager.init() }
advanceUntilIdle()
manager.startLoading()
advanceUntilIdle()
try {
assertions()
} finally {
initJob.cancel()
}
}
private fun TestScope.fakeRepository(
fetcher: BatchFetcher<TxHistoryListConfig, PaginationWrapper<TxInfo>>,
): FakeRepository = FakeRepository(testDispatchers(StandardTestDispatcher(testScheduler)), fetcher)
private fun createManager(repository: FakeRepository): TxHistoryListManager = TxHistoryListManager(
repository = repository,
dispatchers = repository.dispatchers,
userWalletId = userWalletId,
currency = currency,
designFeatureToggles = mockk { every { isRedesignEnabled } returns false },
txHistoryUiActions = mockk(relaxed = true),
lookupDataFlow = emptyFlow(),
legacyTxHistoryItemConverter = mockk<TxHistoryItemToTransactionStateConverter>(relaxed = true),
)
private fun page(itemCount: Int, isLast: Boolean): Page2Spec =
Page2Spec(itemCount = itemCount, isLast = isLast)
private fun testDispatchers(dispatcher: CoroutineDispatcher): CoroutineDispatcherProvider =
object : CoroutineDispatcherProvider {
override val main: CoroutineDispatcher = dispatcher
override val mainImmediate: CoroutineDispatcher = dispatcher
override val io: CoroutineDispatcher = dispatcher
override val default: CoroutineDispatcher = dispatcher
override val single: CoroutineDispatcher = dispatcher
}
/** Page description. The fetcher turns it into a wrapper with a unique cursor, mirroring real pagination. */
private data class Page2Spec(val itemCount: Int, val isLast: Boolean)
private class ScriptedFetcher(
private val pageAt: (call: Int) -> Page2Spec,
) : BatchFetcher<TxHistoryListConfig, PaginationWrapper<TxInfo>> {
var fetchCount = 0
private set
override suspend fun fetchFirst(requestParams: TxHistoryListConfig) = produce()
override suspend fun fetchNext(
overrideRequestParams: TxHistoryListConfig?,
lastResult: BatchFetchResult<PaginationWrapper<TxInfo>>,
) = produce()
private fun produce(): BatchFetchResult<PaginationWrapper<TxInfo>> {
val spec = pageAt(fetchCount)
// A unique cursor per fetch mirrors real pagination (each page has its own paginationToken) and
// prevents StateFlow from conflating two otherwise-identical empty pages.
val wrapper = PaginationWrapper(
currentPage = if (fetchCount == 0) Page.Initial else Page.Next(value = "cursor-$fetchCount"),
nextPage = if (spec.isLast) Page.LastPage else Page.Next(value = "cursor-${fetchCount + 1}"),
items = List(spec.itemCount) { mockk<TxInfo>(relaxed = true) },
)
fetchCount++
return BatchFetchResult.Success(
data = wrapper,
empty = wrapper.items.isEmpty(),
last = spec.isLast,
)
}
}
private class FakeRepository(
val dispatchers: CoroutineDispatcherProvider,
private val fetcher: BatchFetcher<TxHistoryListConfig, PaginationWrapper<TxInfo>>,
) : TxHistoryRepositoryV2 {
private lateinit var batchFlow: TxHistoryListBatchFlow
override fun getTxHistoryBatchFlow(
batchSize: Int,
context: TxHistoryListBatchingContext,
): TxHistoryListBatchFlow = BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 },
batchFetcher = fetcher,
).toBatchFlow().also { batchFlow = it }
fun loadedItemsCount(): Int = batchFlow.state.value.data.sumOf { batch -> batch.data.items.size }
fun status(): PaginationStatus<*> = batchFlow.state.value.status
}
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "releases-5.39-1563"
tangemBlockchainSdk = "releases-5.39-1565"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "releases-5.39-623"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^