Updated on 2026-08-14
This commit is contained in:
parent
f72c0f448e
commit
0a44bcfc70
4 changed files with 256 additions and 1 deletions
|
|
@ -69,4 +69,5 @@ dependencies {
|
|||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.coroutine)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue