From df62694b2f07fa18c2571fb5ebd43c1f628cd434 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 22 Jan 2026 10:24:09 +0100 Subject: [PATCH] Add tests to AddRoomToSpace feature --- .../addroom/AddRoomToSpaceStateProvider.kt | 7 +- .../addroom/AddRoomToSpacePresenterTest.kt | 336 ++++++++++++++++++ .../impl/addroom/AddRoomToSpaceViewTest.kt | 124 +++++++ 3 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt create mode 100644 features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt index 550555135a..6918f135f6 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt @@ -60,13 +60,14 @@ internal class AddRoomToSpaceStateProvider : PreviewParameterProvider> = SearchBarResultState.Initial(), selectedRooms: ImmutableList = persistentListOf(), isSearchActive: Boolean = false, saveAction: AsyncAction = AsyncAction.Uninitialized, suggestions: ImmutableList = persistentListOf(), + eventSink: (AddRoomToSpaceEvent) -> Unit = {}, ): AddRoomToSpaceState { return AddRoomToSpaceState( searchQuery = searchQuery, @@ -75,11 +76,11 @@ private fun anAddRoomToSpaceState( isSearchActive = isSearchActive, saveAction = saveAction, suggestions = suggestions, - eventSink = {}, + eventSink = eventSink, ) } -private fun aSelectRoomInfoList(): ImmutableList = listOf( +internal fun aSelectRoomInfoList(): ImmutableList = listOf( SelectRoomInfo( roomId = RoomId("!room1:server.org"), name = "General", diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt new file mode 100644 index 0000000000..17451cb492 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.space.impl.addroom + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AddRoomToSpacePresenterTest { + @Test + fun `present - initial state has empty selection and no search`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + assertThat(state.selectedRooms).isEmpty() + assertThat(state.searchQuery).isEmpty() + assertThat(state.isSearchActive).isFalse() + assertThat(state.saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(state.canSave).isFalse() + } + } + + @Test + fun `present - ToggleRoom adds room to selection`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val updatedState = awaitItem() + assertThat(updatedState.selectedRooms).hasSize(1) + assertThat(updatedState.selectedRooms.first().roomId).isEqualTo(room.roomId) + assertThat(updatedState.canSave).isTrue() + } + } + + @Test + fun `present - ToggleRoom removes already selected room`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + // Add room + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val stateWithRoom = awaitItem() + assertThat(stateWithRoom.selectedRooms).hasSize(1) + // Remove room + stateWithRoom.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val stateWithoutRoom = awaitItem() + assertThat(stateWithoutRoom.selectedRooms).isEmpty() + assertThat(stateWithoutRoom.canSave).isFalse() + } + } + + @Test + fun `present - UpdateSearchQuery updates query`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test")) + val updatedState = awaitItem() + assertThat(updatedState.searchQuery).isEqualTo("test") + } + } + + @Test + fun `present - OnSearchActiveChanged activates search`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + val updatedState = awaitItem() + assertThat(updatedState.isSearchActive).isTrue() + } + } + + @Test + fun `present - OnSearchActiveChanged deactivates search and clears query`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + // Activate search and set query + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test")) + awaitItem() + // Deactivate search + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false)) + advanceUntilIdle() + val finalState = expectMostRecentItem() + assertThat(finalState.isSearchActive).isFalse() + assertThat(finalState.searchQuery).isEmpty() + } + } + + @Test + fun `present - CloseSearch deactivates and clears query`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + // Activate search and set query + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("test")) + awaitItem() + // Close search + state.eventSink(AddRoomToSpaceEvent.CloseSearch) + advanceUntilIdle() + val finalState = expectMostRecentItem() + assertThat(finalState.isSearchActive).isFalse() + assertThat(finalState.searchQuery).isEmpty() + } + } + + @Test + fun `present - searchResults shows Results when rooms available`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createPresenter(roomListService = roomListService) + presenter.test { + awaitItem() // Initial state + // Post rooms to the service + roomListService.postAllRooms( + listOf( + aRoomSummary( + roomId = A_ROOM_ID, + name = "Room 1", + isDirect = false, + isSpace = false, + currentUserMembership = CurrentUserMembership.JOINED, + ) + ) + ) + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + } + } + + @Test + fun `present - searchResults shows NoResultsFound when search active with query but no results`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(true)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.UpdateSearchQuery("nonexistent")) + advanceUntilIdle() + val finalState = expectMostRecentItem() + assertThat(finalState.isSearchActive).isTrue() + assertThat(finalState.searchQuery).isEqualTo("nonexistent") + assertThat(finalState.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) + } + } + + @Test + fun `present - Save triggers addChildToSpace for all selected rooms`() = runTest { + val addChildToSpaceResult = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val spaceService = FakeSpaceService( + addChildToSpaceResult = addChildToSpaceResult, + ) + val presenter = createPresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + // Select two rooms + val room1 = aSelectRoomInfoList()[0] + val room2 = aSelectRoomInfoList()[1] + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room1)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room2)) + awaitItem() + // Save + state.eventSink(AddRoomToSpaceEvent.Save) + // Wait for loading and success states + skipItems(1) // Loading + advanceUntilIdle() + skipItems(1) // Success + // Verify service was called for both rooms + addChildToSpaceResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - Save success updates saveAction to Success`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ) + val presenter = createPresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.Save) + // Wait for loading state + val loadingState = awaitItem() + assertThat(loadingState.saveAction).isEqualTo(AsyncAction.Loading) + // Wait for success state + advanceUntilIdle() + val successState = awaitItem() + assertThat(successState.saveAction).isInstanceOf(AsyncAction.Success::class.java) + } + } + + @Test + fun `present - Save failure updates saveAction to Failure`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + ) + val presenter = createPresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.Save) + // Wait for loading state + val loadingState = awaitItem() + assertThat(loadingState.saveAction).isEqualTo(AsyncAction.Loading) + // Wait for failure state + advanceUntilIdle() + val failureState = awaitItem() + assertThat(failureState.saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - ResetSaveAction resets to Uninitialized`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ) + val presenter = createPresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + awaitItem() + state.eventSink(AddRoomToSpaceEvent.Save) + skipItems(1) // Loading + advanceUntilIdle() + val successState = awaitItem() + assertThat(successState.saveAction).isInstanceOf(AsyncAction.Success::class.java) + // Reset + successState.eventSink(AddRoomToSpaceEvent.ResetSaveAction) + val resetState = awaitItem() + assertThat(resetState.saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `canSave is false when no rooms selected`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + assertThat(state.selectedRooms).isEmpty() + assertThat(state.canSave).isFalse() + } + } + + @Test + fun `canSave is false when loading`() = runTest { + val spaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ) + val presenter = createPresenter(spaceService = spaceService) + presenter.test { + val state = awaitItem() + val room = aSelectRoomInfoList().first() + state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room)) + val stateWithRoom = awaitItem() + assertThat(stateWithRoom.canSave).isTrue() + stateWithRoom.eventSink(AddRoomToSpaceEvent.Save) + val loadingState = awaitItem() + assertThat(loadingState.saveAction).isEqualTo(AsyncAction.Loading) + assertThat(loadingState.canSave).isFalse() + } + } + + private fun TestScope.createPresenter( + spaceRoomList: FakeSpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) }, + ), + spaceService: FakeSpaceService = FakeSpaceService( + addChildToSpaceResult = { _, _ -> Result.success(Unit) }, + ), + roomListService: FakeRoomListService = FakeRoomListService(), + matrixClient: FakeMatrixClient = FakeMatrixClient( + roomListService = roomListService, + ), + ): AddRoomToSpacePresenter { + val dataSourceFactory = object : AddRoomToSpaceSearchDataSource.Factory { + override fun create(coroutineScope: CoroutineScope) = AddRoomToSpaceSearchDataSource( + coroutineScope = coroutineScope, + roomListService = roomListService, + spaceRoomList = spaceRoomList, + matrixClient = matrixClient, + coroutineDispatchers = testCoroutineDispatchers(), + ) + } + return AddRoomToSpacePresenter( + spaceRoomList = spaceRoomList, + spaceService = spaceService, + dataSourceFactory = dataSourceFactory, + ) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt new file mode 100644 index 0000000000..593a37aa00 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector 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.features.space.impl.addroom + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +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.pressBack +import kotlinx.collections.immutable.toImmutableList +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AddRoomToSpaceViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking back when search inactive invokes onBackClick`() { + ensureCalledOnce { + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + isSearchActive = false, + ), + onBackClick = it, + ) + rule.pressBack() + } + } + + @Test + fun `clicking back when search active emits CloseSearch event`() { + val eventsRecorder = EventsRecorder() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + isSearchActive = true, + eventSink = eventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertSingle(AddRoomToSpaceEvent.CloseSearch) + } + + @Test + fun `clicking save emits Save event`() { + val eventsRecorder = EventsRecorder() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + selectedRooms = aSelectRoomInfoList().take(1).toImmutableList(), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertList( + listOf( + AddRoomToSpaceEvent.UpdateSearchQuery(""), // SearchBar initialization + AddRoomToSpaceEvent.Save, + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking room in suggestions emits ToggleRoom event`() { + val suggestions = aSelectRoomInfoList() + val eventsRecorder = EventsRecorder() + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + suggestions = suggestions, + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(suggestions.first().name!!).performClick() + eventsRecorder.assertList( + listOf( + AddRoomToSpaceEvent.UpdateSearchQuery(""), // SearchBar initialization + AddRoomToSpaceEvent.ToggleRoom(suggestions.first()), + ) + ) + } + + @Test + fun `onRoomsAdded called when saveAction is Success`() { + ensureCalledOnce { + rule.setAddRoomToSpaceView( + anAddRoomToSpaceState( + saveAction = AsyncAction.Success(Unit), + ), + onRoomsAdded = it, + ) + } + } +} + +private fun AndroidComposeTestRule.setAddRoomToSpaceView( + state: AddRoomToSpaceState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomsAdded: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AddRoomToSpaceView( + state = state, + onBackClick = onBackClick, + onRoomsAdded = onRoomsAdded, + ) + } +}