RoomList: refactor and fix tests

This commit is contained in:
ganfra
2024-03-12 15:38:33 +01:00
parent 6ac66d08fd
commit 3cb189f475
20 changed files with 252 additions and 107 deletions

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
open class RoomListContentStateProvider : PreviewParameterProvider<RoomListContentState> {
override val values: Sequence<RoomListContentState>
get() = sequenceOf(
aRoomsContentState(),
aSkeletonContentState(),
anEmptyContentState(),
aMigrationContentState(),
)
}
internal fun aRoomsContentState(
invitesState: InvitesState = InvitesState.NoInvites,
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
) = RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = summaries,
)
internal fun aMigrationContentState() = RoomListContentState.Migration
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState(
invitesState: InvitesState = InvitesState.NoInvites,
) = RoomListContentState.Empty(invitesState)

View File

@@ -40,7 +40,7 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
@@ -89,7 +89,7 @@ class RoomListPresenter @Inject constructor(
private val indicatorService: IndicatorService,
private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>,
private val migrationScreenPresenter: MigrationScreenPresenter,
private val migrationScreenPresenter: Presenter<MigrationScreenState>,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService,
) : Presenter<RoomListState> {
@@ -196,23 +196,23 @@ class RoomListPresenter @Inject constructor(
}
val loadingState by roomListDataSource.loadingState.collectAsState()
val showMigration = migrationScreenPresenter.present().isMigrating
val showSkeleton by remember {
derivedStateOf {
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
val showEmpty by remember {
derivedStateOf {
(loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0
}
}
val showSkeleton by remember {
derivedStateOf {
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
return when {
showMigration -> RoomListContentState.Migration
showSkeleton -> RoomListContentState.Skeleton(count = 16)
showEmpty -> {
val invitesState = inviteStateDataSource.inviteState()
RoomListContentState.Empty(invitesState)
}
showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> {
val invitesState = inviteStateDataSource.inviteState()
val securityBannerState by securityBannerState(securityBannerDismissed)

View File

@@ -67,6 +67,7 @@ enum class SecurityBannerState {
RecoveryKeyConfirmation,
}
@Immutable
sealed interface RoomListContentState {
data object Migration : RoomListContentState
data class Skeleton(val count: Int) : RoomListContentState

View File

@@ -25,7 +25,6 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -35,7 +34,6 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState>
@@ -43,15 +41,15 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(),
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState(hasNetworkConnection = false),
aRoomListState(invitesState = InvitesState.SeenInvites),
aRoomListState(invitesState = InvitesState.NewInvites),
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.SeenInvites)),
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(securityBannerState = SecurityBannerState.SessionVerification),
aRoomListState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
aRoomListState(roomList = AsyncData.Success(persistentListOf())),
//aRoomListState(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
aRoomListState(matrixUser = null, displayMigrationStatus = true),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = null, contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
)
@@ -60,16 +58,13 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
internal fun aRoomListState(
matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator: Boolean = false,
roomList: AsyncData<ImmutableList<RoomListRoomSummary>> = AsyncData.Success(aRoomListRoomSummaryList()),
hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null,
securityBannerState: SecurityBannerState = SecurityBannerState.None,
invitesState: InvitesState = InvitesState.NoInvites,
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
displayMigrationStatus: Boolean = false,
contentState: RoomListContentState = aRoomsContentState(),
eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState(
matrixUser = matrixUser,
@@ -80,11 +75,7 @@ internal fun aRoomListState(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
contentState = RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = roomList.dataOrNull().orEmpty().toPersistentList(),
),
contentState = contentState,
eventSink = eventSink,
)

View File

@@ -17,7 +17,6 @@
package io.element.android.features.roomlist.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -40,6 +39,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -48,19 +48,24 @@ import io.element.android.features.roomlist.impl.InvitesEntryPointView
import io.element.android.features.roomlist.impl.InvitesState
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.RoomListContentState
import io.element.android.features.roomlist.impl.RoomListContentStateProvider
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.SecurityBannerState
import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.filters.RoomListFilter
import io.element.android.features.roomlist.impl.filters.RoomListFiltersEmptyStateResources
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomListContentView(
@@ -214,7 +219,6 @@ private fun RoomsViewList(
modifier = modifier.nestedScroll(nestedScrollConnection),
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
contentPadding = PaddingValues(bottom = 80.dp)
) {
when (state.securityBannerState) {
SecurityBannerState.SessionVerification -> {
@@ -261,7 +265,7 @@ private fun RoomsViewList(
@Composable
private fun EmptyViewForFilterStates(
selectedFilters: List<RoomListFilter>,
selectedFilters: ImmutableList<RoomListFilter>,
modifier: Modifier = Modifier,
) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return
@@ -301,3 +305,18 @@ private fun EmptyScaffold(
action?.invoke(this)
}
}
@PreviewsDayNight
@Composable
internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStateProvider::class) state: RoomListContentState) = ElementPreview {
RoomListContentView(
contentState = state,
filtersState = aRoomListFiltersState(),
eventSink = {},
onVerifyClicked = { },
onConfirmRecoveryKeyClicked = { },
onRoomClicked = {},
onRoomLongClicked = {},
onCreateRoomClicked = { },
onInvitesClicked = { })
}

View File

@@ -29,12 +29,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext

View File

@@ -24,8 +24,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor(
@@ -52,7 +50,6 @@ class RoomListRoomSummaryFactory @Inject constructor(
isFavorite = false,
)
}
}
fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {

View File

@@ -21,6 +21,8 @@ import dagger.Binds
import dagger.Module
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.search.RoomListSearchPresenter
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.Presenter
@@ -34,4 +36,7 @@ interface RoomListModule {
@Binds
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState>
@Binds
fun bindMigrationScreenPresenter(presenter: MigrationScreenPresenter): Presenter<MigrationScreenState>
}

View File

@@ -28,7 +28,6 @@ data class RoomListFiltersEmptyStateResources(
@StringRes val title: Int,
@StringRes val subtitle: Int,
) {
companion object {
/**
* Create a [RoomListFiltersEmptyStateResources] from a list of selected filters.

View File

@@ -34,14 +34,11 @@ class RoomListFiltersPresenter @Inject constructor(
private val featureFlagService: FeatureFlagService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> {
@Composable
override fun present(): RoomListFiltersState {
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomListFilters).collectAsState(false)
val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
fun handleEvents(event: RoomListFiltersEvents) {
when (event) {
RoomListFiltersEvents.ClearSelectedFilters -> {
@@ -75,7 +72,6 @@ class RoomListFiltersPresenter @Inject constructor(
roomListService.allRooms.updateFilter(allRoomsFilter)
}
return RoomListFiltersState(
filterSelectionStates = filters.toPersistentList(),
isFeatureEnabled = isFeatureEnabled,

View File

@@ -18,6 +18,7 @@ package io.element.android.features.roomlist.impl.filters
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
data class RoomListFiltersState(
val filterSelectionStates: ImmutableList<FilterSelectionState>,
@@ -26,7 +27,10 @@ data class RoomListFiltersState(
) {
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }
fun selectedFilters(): List<RoomListFilter> {
return filterSelectionStates.filter { it.isSelected }.map { it.filter }
fun selectedFilters(): ImmutableList<RoomListFilter> {
return filterSelectionStates
.filter { it.isSelected }
.map { it.filter }
.toPersistentList()
}
}

View File

@@ -53,7 +53,6 @@ fun RoomListFiltersView(
state: RoomListFiltersState,
modifier: Modifier = Modifier
) {
fun onClearFiltersClicked() {
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
}

View File

@@ -24,7 +24,6 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStrategy {
private val selectedFilters = LinkedHashSet<RoomListFilter>()
override val filterSelectionStates = MutableStateFlow(buildFilters())

View File

@@ -20,7 +20,6 @@ import io.element.android.features.roomlist.impl.filters.RoomListFilter
import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy {
val filterSelectionStates: StateFlow<Set<FilterSelectionState>>
fun select(filter: RoomListFilter)

View File

@@ -33,8 +33,7 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.model.createRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
@@ -54,13 +53,12 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -75,6 +73,7 @@ import io.element.android.libraries.matrix.test.verification.FakeSessionVerifica
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.MutablePresenter
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -127,7 +126,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenCanVerifySession(false)
@@ -169,11 +167,9 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.roomList.dataOrNull()?.size == 16 }.last()
// Room list is loaded with 16 placeholders
val initialItems = initialState.roomList.dataOrNull().orEmpty()
assertThat(initialItems.size).isEqualTo(16)
assertThat(initialItems.all { it.isPlaceholder }).isTrue()
val initialState = consumeItemsUntilPredicate(timeout = 3.seconds) { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(
listOf(
aRoomSummaryFilled(
@@ -182,10 +178,10 @@ class RoomListPresenterTests {
)
)
)
val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.dataOrNull()?.size == 1 }.last()
val withRoomStateItems = withRoomState.roomList.dataOrNull().orEmpty()
assertThat(withRoomStateItems.size).isEqualTo(1)
assertThat(withRoomStateItems.first()).isEqualTo(
val withRoomsState =
consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Rooms && state.contentAsRooms().summaries.isNotEmpty() }.last()
assertThat(withRoomsState.contentAsRooms().summaries).hasSize(1)
assertThat(withRoomsState.contentAsRooms().summaries.first()).isEqualTo(
createRoomListRoomSummary(
numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2,
@@ -241,23 +237,28 @@ class RoomListPresenterTests {
@Test
fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter(
coroutineScope = scope,
client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
}
},
roomListService = roomListService
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val eventSink = awaitItem().eventSink
val eventSink = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
// For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@@ -265,16 +266,22 @@ class RoomListPresenterTests {
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService),
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventSink = awaitItem().eventSink
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
val eventSink = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().securityBannerState).isEqualTo(SecurityBannerState.None)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@@ -282,7 +289,11 @@ class RoomListPresenterTests {
@Test
fun `present - handle DismissRecoveryKeyPrompt`() = runTest {
val encryptionService = FakeEncryptionService()
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply {
givenCanVerifySession(false)
@@ -297,15 +308,16 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.securityBannerState).isEqualTo(SecurityBannerState.None)
val initialState = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
assertThat(initialState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
val nextState = awaitItem()
assertThat(nextState.securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
assertThat(nextState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
nextState.eventSink(RoomListEvents.DismissRecoveryKeyPrompt)
val finalState = awaitItem()
assertThat(finalState.securityBannerState).isEqualTo(SecurityBannerState.None)
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@@ -314,22 +326,30 @@ class RoomListPresenterTests {
fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
val roomListService = FakeRoomListService()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource, coroutineScope = scope)
val presenter = createRoomListPresenter(
inviteStateDataSource = inviteStateDataSource,
coroutineScope = scope,
client = FakeMatrixClient(roomListService = roomListService),
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites)
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
inviteStateFlow.value = InvitesState.NewInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites)
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NewInvites)
inviteStateFlow.value = InvitesState.NoInvites
assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
scope.cancel()
}
}
@@ -477,6 +497,7 @@ class RoomListPresenterTests {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode)))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
@@ -488,12 +509,13 @@ class RoomListPresenterTests {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state ->
state.roomList.dataOrNull().orEmpty().any { it.id == A_ROOM_ID.value && it.userDefinedNotificationMode == userDefinedMode }
(state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary ->
summary.id == A_ROOM_ID.value && summary.userDefinedNotificationMode == userDefinedMode
}
}.last()
val room = updatedState.roomList.dataOrNull()?.find { it.id == A_ROOM_ID.value }
val room = updatedState.contentAsRooms().summaries.find { it.id == A_ROOM_ID.value }
assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode)
cancelAndIgnoreRemainingEvents()
scope.cancel()
@@ -526,30 +548,46 @@ class RoomListPresenterTests {
}
}
fun `present - change in migration presenter state modifies isMigrating`() = runTest {
val client = FakeMatrixClient(sessionId = A_SESSION_ID)
val migrationStore = InMemoryMigrationScreenStore()
val migrationScreenPresenter = MigrationScreenPresenter(client, migrationStore)
@Test
fun `present - change in migration presenter state modifies contentState`() = runTest {
val migrationScreenPresenter = MutablePresenter(MigrationScreenState(true))
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
client = client,
coroutineScope = scope,
migrationScreenPresenter = migrationScreenPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
// The migration screen is shown if the migration screen has not been shown before
assertThat(initialState.displayMigrationStatus).isTrue()
skipItems(2)
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Migration::class.java)
// Set migration as done and set the room list service as running to trigger a refresh of the presenter value
(client.roomListService as FakeRoomListService).postState(RoomListService.State.Running)
migrationStore.setMigrationScreenShown(A_SESSION_ID)
migrationScreenPresenter.updateState(MigrationScreenState(false))
// The migration screen is not shown anymore
assertThat(awaitItem().displayMigrationStatus).isFalse()
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
scope.cancel()
}
}
@Test
fun `present - when room service returns no room, then contentState is Empty `() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter(
client = matrixClient,
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
scope.cancel()
}
}
@@ -609,10 +647,7 @@ class RoomListPresenterTests {
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
coroutineScope: CoroutineScope,
migrationScreenPresenter: MigrationScreenPresenter = MigrationScreenPresenter(
matrixClient = client,
migrationScreenStore = InMemoryMigrationScreenStore(),
),
migrationScreenPresenter: Presenter<MigrationScreenState> = Presenter { MigrationScreenState(false) },
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
internal fun RoomListState.contentAsRooms() = contentState as RoomListContentState.Rooms

View File

@@ -26,7 +26,6 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -35,7 +34,6 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@@ -50,7 +48,7 @@ class RoomListViewTest {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.SessionVerification,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
)
)
@@ -65,7 +63,7 @@ class RoomListViewTest {
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.SessionVerification,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
),
onVerifyClicked = callback,
@@ -79,7 +77,7 @@ class RoomListViewTest {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.RecoveryKeyConfirmation,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
)
)
@@ -94,7 +92,7 @@ class RoomListViewTest {
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
securityBannerState = SecurityBannerState.RecoveryKeyConfirmation,
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
),
onConfirmRecoveryKeyClicked = callback,
@@ -110,7 +108,7 @@ class RoomListViewTest {
rule.setRoomListView(
state = aRoomListState(
eventSink = eventsRecorder,
roomList = AsyncData.Success(persistentListOf()),
contentState = anEmptyContentState(),
),
onCreateRoomClicked = callback,
)
@@ -124,7 +122,7 @@ class RoomListViewTest {
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.roomList.dataOrNull()!!.first()
val room0 = state.contentAsRooms().summaries.first()
ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView(
state = state,
@@ -140,7 +138,7 @@ class RoomListViewTest {
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.roomList.dataOrNull()!!.first()
val room0 = state.contentAsRooms().summaries.first()
rule.setRoomListView(
state = state,
)
@@ -170,7 +168,7 @@ class RoomListViewTest {
fun `clicking on invites invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
invitesState = InvitesState.NewInvites,
contentState = aRoomsContentState(invitesState = InvitesState.NewInvites),
eventSink = eventsRecorder,
)
ensureCalledOnce { callback ->

View File

@@ -29,6 +29,7 @@ import io.element.android.features.roomlist.impl.datasource.DefaultInviteStateDa
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
import io.element.android.features.roomlist.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.roomlist.impl.migration.MigrationScreenPresenter
import io.element.android.features.roomlist.impl.migration.SharedPrefsMigrationScreenStore
import io.element.android.features.roomlist.impl.search.RoomListSearchDataSource
@@ -127,6 +128,7 @@ class RoomListScreen(
filtersPresenter = RoomListFiltersPresenter(
roomListService = matrixClient.roomListService,
featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
),
analyticsService = NoopAnalyticsService(),
)

View File

@@ -30,6 +30,7 @@ dependencies {
implementation(libs.test.junit)
implementation(libs.test.truth)
implementation(libs.coroutines.test)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.test.turbine)

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.tests.testutils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.flow.MutableStateFlow
class MutablePresenter<State>(initialState: State) : Presenter<State> {
private val stateFlow = MutableStateFlow(initialState)
fun updateState(state: State) {
stateFlow.value = state
}
@Composable
override fun present(): State {
return stateFlow.collectAsState().value
}
}