port messageviewmodel tests to the reducer domain and add more

This commit is contained in:
Adam Brown 2022-11-01 12:13:31 +00:00
parent a5b7ede2d8
commit b9dda51112
4 changed files with 413 additions and 152 deletions

View File

@ -10,8 +10,6 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.internal.assertEquals
import org.amshove.kluent.shouldBeEqualTo
import test.ExpectTest
import test.ExpectTestScope
interface ReducerTest<S, E> {
operator fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit)
@ -63,16 +61,31 @@ class ReducerTestScope<S, E>(
manualState = state
}
fun setState(block: (S) -> S) {
manualState = block(reducerScope.getState())
}
fun assertInitialState(expected: S) {
reducerFactory.initialState() shouldBeEqualTo expected
}
fun assertEvents(events: List<E>) {
fakeEventSource.assertEvents(events)
}
fun assertOnlyStateChange(expected: S) {
assertStateChange(expected)
assertNoDispatches()
fakeEventSource.assertNoEvents()
}
fun assertOnlyStateChange(block: (S) -> S) {
val expected = block(reducerScope.getState())
assertStateChange(expected)
assertNoDispatches()
fakeEventSource.assertNoEvents()
}
fun assertStateChange(expected: S) {
capturedResult shouldBeEqualTo expected
}
@ -86,7 +99,11 @@ class ReducerTestScope<S, E>(
}
fun assertNoStateChange() {
assertEquals(reducerFactory.initialState(), capturedResult)
assertEquals(reducerScope.getState(), capturedResult)
}
fun assertNoEvents() {
fakeEventSource.assertNoEvents()
}
fun assertOnlyDispatches(expected: List<Action>) {
@ -103,7 +120,7 @@ class ReducerTestScope<S, E>(
fun assertNoChanges() {
assertNoStateChange()
fakeEventSource.assertNoEvents()
assertNoEvents()
assertNoDispatches()
}
}

View File

@ -9,6 +9,7 @@ import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.domain.application.message.MessageOptionsStore
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.MessengerPageState
import app.dapk.st.engine.RoomEvent
import app.dapk.st.engine.SendMessage
import app.dapk.st.matrix.common.RoomId
@ -33,7 +34,7 @@ internal fun messengerReducer(
initialState = MessengerScreenState(
roomId = roomId,
roomState = Lce.Loading(),
composerState = initialAttachments?.let { ComposerState.Attachments(it, null) } ?: ComposerState.Text(value = "", reply = null),
composerState = initialComposerState(initialAttachments),
viewerState = null,
),
@ -120,32 +121,12 @@ internal fun messengerReducer(
is ComposerState.Text -> {
dispatch(ComposerStateChange.Clear)
state.roomState.takeIfContent()?.let { content ->
val roomState = content.roomState
chatEngine.send(
message = SendMessage.TextMessage(
content = composerState.value,
reply = composerState.reply?.let {
SendMessage.TextMessage.Reply(
author = it.author,
originalMessage = when (it) {
is RoomEvent.Image -> TODO()
is RoomEvent.Reply -> TODO()
is RoomEvent.Message -> it.content.asString()
is RoomEvent.Encrypted -> error("Should never happen")
},
eventId = it.eventId,
timestampUtc = it.utcTimestamp,
)
}
),
room = roomState.roomOverview,
)
chatEngine.sendTextMessage(content, composerState)
}
}
is ComposerState.Attachments -> {
dispatch(ComposerStateChange.Clear)
state.roomState.takeIfContent()?.let { content ->
val roomState = content.roomState
chatEngine.send(SendMessage.ImageMessage(uri = composerState.values.first().uri.value), roomState.roomOverview)
@ -156,6 +137,33 @@ internal fun messengerReducer(
)
}
private suspend fun ChatEngine.sendTextMessage(content: MessengerPageState, composerState: ComposerState.Text) {
val roomState = content.roomState
val message = SendMessage.TextMessage(
content = composerState.value,
reply = composerState.reply?.toSendMessageReply(),
)
this.send(message = message, room = roomState.roomOverview)
}
private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
author = this.author,
originalMessage = when (this) {
is RoomEvent.Image -> TODO()
is RoomEvent.Reply -> TODO()
is RoomEvent.Message -> this.content.asString()
is RoomEvent.Encrypted -> error("Should never happen")
},
eventId = this.eventId,
timestampUtc = this.utcTimestamp,
)
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
?.takeIf { it.isNotEmpty() }
?.let { ComposerState.Attachments(it, null) }
?: ComposerState.Text(value = "", reply = null)
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
is BubbleModel.Image -> CopyableResult.NothingToCopy

View File

@ -0,0 +1,362 @@
package app.dapk.st.messenger
import android.os.Build
import app.dapk.st.core.*
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.engine.RoomEvent
import app.dapk.st.engine.RoomState
import app.dapk.st.engine.SendMessage
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.common.asString
import app.dapk.st.messenger.state.*
import app.dapk.st.navigator.MessageAttachment
import fake.FakeChatEngine
import fake.FakeMessageOptionsStore
import fixture.*
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
import test.ReducerTestScope
import test.delegateReturn
import test.expect
import test.testReducer
private const val READ_RECEIPTS_ARE_DISABLED = true
private val A_ROOM_ID = aRoomId("messenger state room id")
private const val A_MESSAGE_CONTENT = "message content"
private val AN_EVENT_ID = anEventId("state event")
private val A_SELF_ID = aUserId("self")
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
private val A_REPLY = aRoomReplyMessageEvent()
private val AN_IMAGE_BUBBLE = BubbleModel.Image(
BubbleModel.Image.ImageContent(100, 200, "a-url"),
mockk(),
BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27")
)
private val A_TEXT_BUBBLE = BubbleModel.Text(
content = RichText(listOf(RichText.Part.Normal(A_MESSAGE_CONTENT))),
BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27")
)
class MessengerReducerTest {
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
private val fakeChatEngine = FakeChatEngine()
private val fakeCopyToClipboard = FakeCopyToClipboard()
private val fakeDeviceMeta = FakeDeviceMeta()
private val fakeJobBag = FakeJobBag()
private val runReducerTest = testReducer { fakeEventSource ->
messengerReducer(
fakeJobBag.instance,
fakeChatEngine,
fakeCopyToClipboard.instance,
fakeDeviceMeta.instance,
fakeMessageOptionsStore.instance,
A_ROOM_ID,
emptyList(),
fakeEventSource,
)
}
@Test
fun `given empty initial attachments, then initial state is loading with text composer`() = reducerWithInitialState(initialAttachments = emptyList()) {
assertInitialState(
MessengerScreenState(
roomId = A_ROOM_ID,
roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
)
)
}
@Test
fun `given null initial attachments, then initial state is loading with text composer`() = reducerWithInitialState(initialAttachments = null) {
assertInitialState(
MessengerScreenState(
roomId = A_ROOM_ID,
roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
)
)
}
@Test
fun `given initial attachments, then initial state is loading attachment composer`() = reducerWithInitialState(listOf(A_MESSAGE_ATTACHMENT)) {
assertInitialState(
MessengerScreenState(
roomId = A_ROOM_ID,
roomState = Lce.Loading(),
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
viewerState = null,
)
)
}
@Test
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest {
fakeJobBag.instance.expect { it.add("messages", any()) }
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
reduce(ComponentLifecycle.Visible)
assertOnlyDispatches(listOf(MessagesStateChange.Content(state)))
}
@Test
fun `when Gone, then cancels sync job`() = runReducerTest {
fakeJobBag.instance.expect { it.cancel("messages") }
reduce(ComponentLifecycle.Gone)
assertNoChanges()
}
@Test
fun `when Content StateChange, then updates room state`() = runReducerTest {
reduce(MessagesStateChange.Content(A_MESSENGER_PAGE_STATE))
assertOnlyStateChange { previous ->
previous.copy(roomState = Lce.Content(A_MESSENGER_PAGE_STATE))
}
}
@Test
fun `when SelectAttachmentToSend, then updates composer state`() = runReducerTest {
reduce(ComposerStateChange.SelectAttachmentToSend(A_MESSAGE_ATTACHMENT))
assertOnlyStateChange { previous ->
previous.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null))
}
}
@Test
fun `when Show ImagePreview, then updates viewer state`() = runReducerTest {
reduce(ComposerStateChange.ImagePreview.Show(AN_IMAGE_BUBBLE))
assertOnlyStateChange { previous ->
previous.copy(viewerState = ViewerState(AN_IMAGE_BUBBLE))
}
}
@Test
fun `when Hide ImagePreview, then updates viewer state`() = runReducerTest {
reduce(ComposerStateChange.ImagePreview.Hide)
assertOnlyStateChange { previous ->
previous.copy(viewerState = null)
}
}
@Test
fun `when TextUpdate StateChange, then updates composer state`() = runReducerTest {
reduce(ComposerStateChange.TextUpdate(A_MESSAGE_CONTENT))
assertOnlyStateChange { previous ->
previous.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null))
}
}
@Test
fun `when Clear ComposerStateChange, then clear composer state`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null)) }
reduce(ComposerStateChange.Clear)
assertOnlyStateChange { previous ->
previous.copy(composerState = ComposerState.Text(value = "", reply = null))
}
}
@Test
fun `given text composer, when Enter ReplyMode, then updates composer state with reply`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null)) }
reduce(ComposerStateChange.ReplyMode.Enter(A_REPLY))
assertOnlyStateChange { previous ->
previous.copy(composerState = (previous.composerState as ComposerState.Text).copy(reply = A_REPLY))
}
}
@Test
fun `given text composer, when Exit ReplyMode, then updates composer state`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY)) }
reduce(ComposerStateChange.ReplyMode.Exit)
assertOnlyStateChange { previous ->
previous.copy(composerState = (previous.composerState as ComposerState.Text).copy(reply = null))
}
}
@Test
fun `given attachment composer, when Enter ReplyMode, then updates composer state with reply`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null)) }
reduce(ComposerStateChange.ReplyMode.Enter(A_REPLY))
assertOnlyStateChange { previous ->
previous.copy(composerState = (previous.composerState as ComposerState.Attachments).copy(reply = A_REPLY))
}
}
@Test
fun `given attachment composer, when Exit ReplyMode, then updates composer state`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = A_REPLY)) }
reduce(ComposerStateChange.ReplyMode.Exit)
assertOnlyStateChange { previous ->
previous.copy(composerState = (previous.composerState as ComposerState.Attachments).copy(reply = null))
}
}
@Test
fun `when OpenGalleryPicker, then emits event`() = runReducerTest {
reduce(ScreenAction.OpenGalleryPicker)
assertOnlyEvents(listOf(MessengerEvent.SelectImageAttachment))
}
@Test
fun `given android api is lower than S_v2 and has text content, when CopyToClipboard, then copies to system and toasts`() = runReducerTest {
fakeDeviceMeta.givenApiVersion().returns(Build.VERSION_CODES.S)
fakeCopyToClipboard.instance.expect { it.copy(CopyToClipboard.Copyable.Text(A_MESSAGE_CONTENT)) }
reduce(ScreenAction.CopyToClipboard(A_TEXT_BUBBLE))
assertEvents(listOf(MessengerEvent.Toast("Copied to clipboard")))
assertNoDispatches()
assertNoStateChange()
}
@Test
fun `given android api is higher than S_v2 and has text content, when CopyToClipboard, then copies to system and does not toast`() = runReducerTest {
fakeDeviceMeta.givenApiVersion().returns(Build.VERSION_CODES.TIRAMISU)
fakeCopyToClipboard.instance.expect { it.copy(CopyToClipboard.Copyable.Text(A_MESSAGE_CONTENT)) }
reduce(ScreenAction.CopyToClipboard(A_TEXT_BUBBLE))
assertNoChanges()
}
@Test
fun `given image content, when CopyToClipboard, then toasts nothing to copy`() = runReducerTest {
reduce(ScreenAction.CopyToClipboard(AN_IMAGE_BUBBLE))
assertEvents(listOf(MessengerEvent.Toast("Nothing to copy")))
assertNoDispatches()
assertNoStateChange()
}
@Test
fun `given text composer, when SendMessage, then clear composer and sends text message`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
reduce(ScreenAction.SendMessage)
assertDispatches(listOf(ComposerStateChange.Clear))
assertNoEvents()
assertNoStateChange()
}
@Test
fun `given text composer with reply, when SendMessage, then clear composer and sends text message`() = runReducerTest {
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY.message), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT, reply = A_REPLY.message), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
reduce(ScreenAction.SendMessage)
assertDispatches(listOf(ComposerStateChange.Clear))
assertNoEvents()
assertNoStateChange()
}
@Test
fun `given attachment composer, when SendMessage, then clear composer and sends image message`() = runReducerTest {
setState {
it.copy(
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
roomState = Lce.Content(A_MESSENGER_PAGE_STATE)
)
}
fakeChatEngine.expectUnit { it.send(expectImageMessage(A_MESSAGE_ATTACHMENT.uri), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
reduce(ScreenAction.SendMessage)
assertDispatches(listOf(ComposerStateChange.Clear))
assertNoEvents()
assertNoStateChange()
}
private fun expectTextMessage(messageContent: String, reply: RoomEvent? = null): SendMessage.TextMessage {
return SendMessage.TextMessage(messageContent, reply = reply?.toSendMessageReply())
}
private fun expectImageMessage(uri: AndroidUri): SendMessage.ImageMessage {
return SendMessage.ImageMessage(uri.value)
}
private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
author = this.author,
originalMessage = when (this) {
is RoomEvent.Image -> TODO()
is RoomEvent.Reply -> TODO()
is RoomEvent.Message -> this.content.asString()
is RoomEvent.Encrypted -> error("Should never happen")
},
eventId = this.eventId,
timestampUtc = this.utcTimestamp,
)
private fun reducerWithInitialState(
initialAttachments: List<MessageAttachment>?,
block: suspend ReducerTestScope<MessengerScreenState, MessengerEvent>.() -> Unit
) = testReducer { fakeEventSource ->
messengerReducer(
fakeJobBag.instance,
fakeChatEngine,
fakeCopyToClipboard.instance,
fakeDeviceMeta.instance,
fakeMessageOptionsStore.instance,
A_ROOM_ID,
initialAttachments = initialAttachments,
fakeEventSource,
)
}(block)
}
private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId)
private fun aRoomStateWithEventId(eventId: EventId): RoomState {
val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1)
return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element))
}
private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this)
class FakeCopyToClipboard {
val instance = mockk<CopyToClipboard>()
}
class FakeJobBag {
val instance = mockk<JobBag>()
}
class FakeDeviceMeta {
val instance = mockk<DeviceMeta>()
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
}

View File

@ -1,126 +0,0 @@
package app.dapk.st.messenger
import ViewModelTest
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.takeIfContent
import app.dapk.st.engine.MessengerPageState
import app.dapk.st.engine.RoomState
import app.dapk.st.engine.SendMessage
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.UserId
import app.dapk.st.messenger.state.ComposerState
import app.dapk.st.messenger.state.MessengerScreenState
import fake.FakeChatEngine
import fake.FakeMessageOptionsStore
import fixture.*
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import org.junit.Test
private const val READ_RECEIPTS_ARE_DISABLED = true
private val A_ROOM_ID = aRoomId("messenger state room id")
private const val A_MESSAGE_CONTENT = "message content"
private val AN_EVENT_ID = anEventId("state event")
private val A_SELF_ID = aUserId("self")
class MessengerViewModelTest {
private val runViewModelTest = ViewModelTest()
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
private val fakeChatEngine = FakeChatEngine()
private val fakeCopyToClipboard = FakeCopyToClipboard()
private val deviceMeta = DeviceMeta(26)
private val viewModel = MessengerViewModel(
fakeChatEngine,
fakeMessageOptionsStore.instance,
fakeCopyToClipboard.instance,
deviceMeta,
factory = runViewModelTest.testMutableStateFactory(),
)
@Test
fun `when creating view model, then initial state is loading room state`() = runViewModelTest {
viewModel.test()
assertInitialState(
MessengerScreenState(
roomId = null,
roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
)
)
}
@Test
fun `given timeline emits state, when starting, then updates state and marks room and events as read`() = runViewModelTest {
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID, attachments = null))
assertStates<MessengerScreenState>(
{ copy(roomId = A_ROOM_ID) },
{ copy(roomState = Lce.Content(state)) },
)
verifyExpects()
}
@Test
fun `when posting composer update, then updates state`() = runViewModelTest {
viewModel.test().post(MessengerAction.ComposerTextUpdate(A_MESSAGE_CONTENT))
assertStates<MessengerScreenState>({
copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null))
})
}
@Test
fun `given composer message state when posting send text, then resets composer state and sends message`() = runViewModelTest {
val initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT)
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), initialState.roomState.takeIfContent()!!.roomState.roomOverview) }
viewModel.test(initialState = initialState).post(MessengerAction.ComposerSendText)
assertStates<MessengerScreenState>({ copy(composerState = ComposerState.Text("", reply = null)) })
verifyExpects()
}
private fun initialStateWithComposerMessage(roomId: RoomId, messageContent: String): MessengerScreenState {
val roomState = RoomState(
aRoomOverview(roomId = roomId, isEncrypted = true),
listOf(anEncryptedRoomMessageEvent(utcTimestamp = 1))
)
return aMessageScreenState(roomId, aMessengerState(roomState = roomState), messageContent)
}
private fun expectTextMessage(messageContent: String): SendMessage.TextMessage {
return SendMessage.TextMessage(messageContent, reply = null)
}
private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId)
private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this)
private fun aRoomStateWithEventId(eventId: EventId): RoomState {
val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1)
return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element))
}
}
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerPageState, messageContent: String?) = MessengerScreenState(
roomId = roomId,
roomState = Lce.Content(roomState),
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
viewerState = null,
)
class FakeCopyToClipboard {
val instance = mockk<CopyToClipboard>()
}