Add UserStatusPresenter with full test coverage

This commit is contained in:
ganfra
2026-06-12 15:37:28 +02:00
parent eca45b574e
commit 0528b787c9
3 changed files with 197 additions and 2 deletions
@@ -0,0 +1,63 @@
/*
* 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.features.preferences.impl.userstatus
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.user.UserStatus
import kotlinx.coroutines.launch
@Inject
class UserStatusPresenter(
private val matrixClient: MatrixClient,
) : Presenter<UserStatusState> {
@Composable
override fun present(): UserStatusState {
val userProfile by matrixClient.userProfile.collectAsState()
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: UserStatusEvent) {
when (event) {
UserStatusEvent.Open -> pickerState = UserStatusPickerState.ShowingPicker
UserStatusEvent.Dismiss -> pickerState = UserStatusPickerState.Hidden
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
pickerState = UserStatusPickerState.CustomInput(
initialEmoji = raw?.emoji ?: "😀",
initialText = raw?.text ?: "",
)
}
is UserStatusEvent.Set -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
}
UserStatusEvent.Clear -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
}
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
}
}
return UserStatusState(
displayedStatus = userProfile.displayedStatus,
pickerState = pickerState,
eventSink = ::handleEvent,
)
}
}
@@ -0,0 +1,132 @@
/*
* 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.features.preferences.impl.userstatus
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserStatusPresenterTest {
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
UserStatusPresenter(matrixClient = client)
@Test
fun `initial state - no status set`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
createPresenter().present()
}.test {
val state = awaitItem()
assertThat(state.displayedStatus).isNull()
assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Open event transitions to ShowingPicker`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
createPresenter().present()
}.test {
val state = awaitItem()
state.eventSink(UserStatusEvent.Open)
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker)
}
}
@Test
fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
createPresenter().present()
}.test {
val state = awaitItem()
state.eventSink(UserStatusEvent.Open)
awaitItem() // ShowingPicker
state.eventSink(UserStatusEvent.Dismiss)
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest {
val client = FakeMatrixClient()
moleculeFlow(RecompositionMode.Immediate) {
createPresenter(client).present()
}.test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.Set(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `OpenCustomInput with no existing status uses defaults`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
createPresenter().present()
}.test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.initialEmoji).isEqualTo("😀")
assertThat(pickerState.initialText).isEqualTo("")
}
}
@Test
fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest {
val client = FakeMatrixClient()
moleculeFlow(RecompositionMode.Immediate) {
createPresenter(client).present()
}.test {
val status = UserStatus(emoji = "🌴", text = "Away")
awaitItem().eventSink(UserStatusEvent.Set(status))
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
val state = awaitItem()
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
assertThat(pickerState.initialEmoji).isEqualTo("🌴")
assertThat(pickerState.initialText).isEqualTo("Away")
}
}
@Test
fun `CancelCustomInput goes back to Hidden`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
createPresenter().present()
}.test {
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
}
}
@Test
fun `Clear event calls clearUserStatus and clears displayed status`() = runTest {
val client = FakeMatrixClient()
moleculeFlow(RecompositionMode.Immediate) {
createPresenter(client).present()
}.test {
val status = UserStatus(emoji = "", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.Set(status))
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
awaitItem().eventSink(UserStatusEvent.Clear)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull()
assertThat(client.clearUserStatusCalled).isTrue()
}
}
}
@@ -258,7 +258,7 @@ class FakeMatrixClient(
return removeAvatarResult
}
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = simulateLongTask {
override suspend fun setUserStatus(status: UserStatus): Result<Unit> {
setUserStatusCalled = true
if (setUserStatusResult.isSuccess) {
_userProfile.emit(_userProfile.value.copy(
@@ -269,7 +269,7 @@ class FakeMatrixClient(
return setUserStatusResult
}
override suspend fun clearUserStatus(): Result<Unit> = simulateLongTask {
override suspend fun clearUserStatus(): Result<Unit> {
clearUserStatusCalled = true
if (clearUserStatusResult.isSuccess) {
_userProfile.emit(_userProfile.value.copy(