Add tests and clean code after RoomList Filter rework
This commit is contained in:
parent
0824a3ab8b
commit
9641d3ef4f
27 changed files with 325 additions and 161 deletions
|
|
@ -12,17 +12,11 @@ import androidx.compose.foundation.gestures.Orientation
|
|||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Returns whether the lazy list is currently scrolling up.
|
||||
|
|
@ -79,20 +73,3 @@ suspend fun LazyListState.animateScrollToItemCenter(index: Int) {
|
|||
animateScrollToItem(index, offset)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) {
|
||||
val onChangeUpdated by rememberUpdatedState(onChange)
|
||||
LaunchedEffect(lazyListState) {
|
||||
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
|
||||
.map { visibleItemsInfo ->
|
||||
val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0
|
||||
val size = visibleItemsInfo.size
|
||||
firstItemIndex until firstItemIndex + size
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { visibleRange ->
|
||||
onChangeUpdated(visibleRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.utils
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) {
|
||||
val onChangeUpdated by rememberUpdatedState(onChange)
|
||||
LaunchedEffect(lazyListState) {
|
||||
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
|
||||
.map { visibleItemsInfo ->
|
||||
val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0
|
||||
val size = visibleItemsInfo.size
|
||||
firstItemIndex until firstItemIndex + size
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { visibleRange ->
|
||||
onChangeUpdated(visibleRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,6 @@
|
|||
|
||||
package io.element.android.libraries.matrix.api.roomlist
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* RoomList with dynamic filtering and loading.
|
||||
* This is useful for large lists of rooms.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import kotlin.time.Duration
|
|||
* Can be retrieved from [RoomListService] methods.
|
||||
*/
|
||||
interface RoomList {
|
||||
|
||||
/**
|
||||
* The loading state of the room list.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -18,13 +18,11 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.matrix.rustcomponents.sdk.Room
|
||||
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
|
||||
import org.matrix.rustcomponents.sdk.RoomListInterface
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingState
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
|
||||
|
|
|
|||
|
|
@ -90,8 +90,6 @@ internal class RoomListFactory(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
|
||||
return when (this) {
|
||||
is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import org.matrix.rustcomponents.sdk.RoomListFilterCategory
|
|||
* Mapper for converting RoomListFilter to Rust SDK filter kinds.
|
||||
*/
|
||||
internal object RoomListFilterMapper {
|
||||
|
||||
/**
|
||||
* Base rust filters to always apply across all room lists.
|
||||
* These filters ensure we show:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ internal class RustDynamicRoomList(
|
|||
private val dynamicController: () -> RoomListDynamicEntriesController?,
|
||||
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
|
||||
) : DynamicRoomList {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
override suspend fun rebuildSummaries() {
|
||||
|
|
|
|||
|
|
@ -13,31 +13,30 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
|
|||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
|
||||
data class SimplePagedRoomList(
|
||||
override val summaries: MutableStateFlow<List<RoomSummary>>,
|
||||
override val loadingState: StateFlow<RoomList.LoadingState>,
|
||||
private val currentFilter: MutableStateFlow<RoomListFilter>
|
||||
class FakeDynamicRoomList(
|
||||
override val summaries: MutableStateFlow<List<RoomSummary>> = MutableStateFlow(emptyList()),
|
||||
override val loadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded),
|
||||
override val pageSize: Int = Int.MAX_VALUE,
|
||||
val currentFilter: MutableStateFlow<RoomListFilter> = MutableStateFlow(RoomListFilter.None),
|
||||
private val loadMoreLambda: () -> Unit = {},
|
||||
private val resetLambda: () -> Unit = {},
|
||||
private val updateFilterLambda: (RoomListFilter) -> Unit = { filter -> currentFilter.value = filter },
|
||||
private val rebuildSummariesLambda: () -> Unit = {},
|
||||
) : DynamicRoomList {
|
||||
override val pageSize: Int = Int.MAX_VALUE
|
||||
private val loadedPages = MutableStateFlow(1)
|
||||
|
||||
override suspend fun loadMore() {
|
||||
// No-op
|
||||
loadedPages.getAndUpdate { it + 1 }
|
||||
loadMoreLambda()
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
loadedPages.emit(1)
|
||||
resetLambda()
|
||||
}
|
||||
|
||||
override suspend fun updateFilter(filter: RoomListFilter) {
|
||||
currentFilter.emit(filter)
|
||||
updateFilterLambda(filter)
|
||||
}
|
||||
|
||||
override suspend fun rebuildSummaries() {
|
||||
// No-op
|
||||
rebuildSummariesLambda()
|
||||
}
|
||||
}
|
||||
|
|
@ -11,29 +11,19 @@ package io.element.android.libraries.matrix.test.roomlist
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class FakeRoomListService(
|
||||
var subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
|
||||
private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
|
||||
private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) },
|
||||
override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE),
|
||||
) : RoomListService {
|
||||
private val allRoomSummariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
|
||||
private val allRoomsLoadingStateFlow = MutableStateFlow<RoomList.LoadingState>(RoomList.LoadingState.NotLoaded)
|
||||
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
|
||||
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
|
||||
|
||||
suspend fun postAllRooms(roomSummaries: List<RoomSummary>) {
|
||||
allRoomSummariesFlow.emit(roomSummaries)
|
||||
}
|
||||
|
||||
suspend fun postAllRoomsLoadingState(loadingState: RoomList.LoadingState) {
|
||||
allRoomsLoadingStateFlow.emit(loadingState)
|
||||
}
|
||||
|
||||
suspend fun postState(state: RoomListService.State) {
|
||||
roomListStateFlow.emit(state)
|
||||
}
|
||||
|
|
@ -46,22 +36,12 @@ class FakeRoomListService(
|
|||
pageSize: Int,
|
||||
source: RoomList.Source,
|
||||
coroutineScope: CoroutineScope,
|
||||
): DynamicRoomList {
|
||||
return when (source) {
|
||||
RoomList.Source.All -> allRooms
|
||||
}
|
||||
}
|
||||
) = createRoomListLambda(pageSize)
|
||||
|
||||
override suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
|
||||
subscribeToVisibleRoomsLambda(roomIds)
|
||||
}
|
||||
|
||||
override val allRooms = SimplePagedRoomList(
|
||||
allRoomSummariesFlow,
|
||||
allRoomsLoadingStateFlow,
|
||||
MutableStateFlow(RoomListFilter.all())
|
||||
)
|
||||
|
||||
override val state: StateFlow<RoomListService.State> = roomListStateFlow
|
||||
|
||||
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = syncIndicatorStateFlow
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ plugins {
|
|||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.roomselect.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
|
@ -30,6 +36,6 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
api(projects.libraries.roomselect.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testCommonDependencies(libs, includeTestComposeView = true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ sealed interface RoomSelectEvents {
|
|||
// TODO remove to restore multi-selection
|
||||
data object RemoveSelectedRoom : RoomSelectEvents
|
||||
data object ToggleSearchActive : RoomSelectEvents
|
||||
data class UpdateVisibleRange(val range: IntRange): RoomSelectEvents
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,22 +43,23 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
|
|||
)
|
||||
}
|
||||
|
||||
private fun aRoomSelectState(
|
||||
internal fun aRoomSelectState(
|
||||
mode: RoomSelectMode = RoomSelectMode.Forward,
|
||||
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
|
||||
eventSink: (RoomSelectEvents) -> Unit = {},
|
||||
) = RoomSelectState(
|
||||
mode = mode,
|
||||
resultState = resultState,
|
||||
searchQuery = TextFieldState(initialText = searchQuery),
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
eventSink = {}
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
private fun aRoomSelectRoomList() = persistentListOf(
|
||||
internal fun aRoomSelectRoomList() = persistentListOf(
|
||||
aSelectRoomInfo(
|
||||
roomId = RoomId("!room1:domain"),
|
||||
name = "Room with name",
|
||||
|
|
|
|||
|
|
@ -17,13 +17,17 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
|
|||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
|
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
|
|
@ -63,9 +67,12 @@ class RoomSelectPresenterTest {
|
|||
@Test
|
||||
fun `present - update query`() = runTest {
|
||||
val roomSummary = aRoomSummary()
|
||||
val roomListService = FakeRoomListService().apply {
|
||||
postAllRooms(listOf(roomSummary))
|
||||
}
|
||||
val roomList = FakeDynamicRoomList(
|
||||
summaries = MutableStateFlow(listOf(roomSummary))
|
||||
)
|
||||
val roomListService = FakeRoomListService(
|
||||
createRoomListLambda = { roomList }
|
||||
)
|
||||
val presenter = createRoomSelectPresenter(
|
||||
roomListService = roomListService
|
||||
)
|
||||
|
|
@ -81,12 +88,12 @@ class RoomSelectPresenterTest {
|
|||
skipItems(1)
|
||||
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
|
||||
assertThat(
|
||||
roomListService.allRooms.currentFilter.value
|
||||
roomList.currentFilter.value
|
||||
).isEqualTo(
|
||||
RoomListFilter.NormalizedMatchRoomName("string not contained")
|
||||
)
|
||||
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained")
|
||||
roomListService.postAllRooms(
|
||||
roomList.summaries.emit(
|
||||
emptyList()
|
||||
)
|
||||
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
|
||||
|
|
@ -96,9 +103,12 @@ class RoomSelectPresenterTest {
|
|||
@Test
|
||||
fun `present - select and remove a room`() = runTest {
|
||||
val roomSummary = aRoomSummary()
|
||||
val roomListService = FakeRoomListService().apply {
|
||||
postAllRooms(listOf(roomSummary))
|
||||
}
|
||||
val roomList = FakeDynamicRoomList(
|
||||
summaries = MutableStateFlow(listOf(roomSummary))
|
||||
)
|
||||
val roomListService = FakeRoomListService(
|
||||
createRoomListLambda = { roomList }
|
||||
)
|
||||
val presenter = createRoomSelectPresenter(
|
||||
roomListService = roomListService,
|
||||
)
|
||||
|
|
@ -114,6 +124,35 @@ class RoomSelectPresenterTest {
|
|||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
|
||||
val loadMoreLambda = lambdaRecorder<Unit> { }
|
||||
val roomList = FakeDynamicRoomList(
|
||||
summaries = MutableStateFlow(listOf()),
|
||||
loadMoreLambda = loadMoreLambda,
|
||||
)
|
||||
val roomListService = FakeRoomListService(
|
||||
createRoomListLambda = { roomList }
|
||||
)
|
||||
val presenter = createRoomSelectPresenter(roomListService = roomListService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
// Post some rooms to simulate loaded content
|
||||
val rooms = (1..10).map { aRoomSummary() }
|
||||
roomList.summaries.emit(rooms)
|
||||
skipItems(1)
|
||||
|
||||
// UpdateVisibleRange near end should trigger loadMore
|
||||
initialState.eventSink(RoomSelectEvents.UpdateVisibleRange(IntRange(0, 9)))
|
||||
// Give time for the coroutine to complete
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assert(loadMoreLambda).isCalledOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun TestScope.createRoomSelectPresenter(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue