Add tests and clean code after RoomList Filter rework

This commit is contained in:
ganfra 2026-01-30 15:03:50 +01:00
parent 0824a3ab8b
commit 9641d3ef4f
27 changed files with 325 additions and 161 deletions

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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.

View file

@ -21,7 +21,6 @@ import kotlin.time.Duration
* Can be retrieved from [RoomListService] methods.
*/
interface RoomList {
/**
* The loading state of the room list.
*/

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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() {

View file

@ -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()
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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",

View file

@ -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(