diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index a5868be..72443f0 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -71,7 +71,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { private val driver = AndroidSqliteDriver(DapkDb.Schema, context, "dapk.db") private val database = DapkDb(driver) - + private val clock = Clock.systemUTC() private val coroutineDispatchers = CoroutineDispatchers(Dispatchers.IO) val storeModule = unsafeLazy { @@ -116,6 +116,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) { context, buildMeta, coroutineDispatchers, + clock, ) } @@ -128,6 +129,7 @@ internal class FeatureModules internal constructor( context: Context, buildMeta: BuildMeta, coroutineDispatchers: CoroutineDispatchers, + clock: Clock, ) { val directoryModule by unsafeLazy { @@ -155,6 +157,7 @@ internal class FeatureModules internal constructor( matrixModules.room, storeModule.value.credentialsStore(), storeModule.value.roomStore(), + clock ) } val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile) } diff --git a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt index 127b31f..8da3051 100644 --- a/core/src/testFixtures/kotlin/test/ExpectTestScope.kt +++ b/core/src/testFixtures/kotlin/test/ExpectTestScope.kt @@ -3,7 +3,7 @@ package test import io.mockk.MockKMatcherScope import io.mockk.MockKVerificationScope import io.mockk.coJustRun -import io.mockk.coVerifyAll +import io.mockk.coVerify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import kotlin.coroutines.CoroutineContext @@ -15,13 +15,15 @@ fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) { class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope { - private val expects = mutableListOf Unit>() + private val expects = mutableListOf Unit>>() - override fun verifyExpects() = coVerifyAll { expects.forEach { it.invoke(this@coVerifyAll) } } + override fun verifyExpects() = expects.forEach { (times, block) -> + coVerify(exactly = times) { block.invoke(this) } + } - override fun T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) { + override fun T.expectUnit(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) { coJustRun { block(this@expectUnit) }.ignore() - expects.add { block(this@expectUnit) } + expects.add(times to { block(this@expectUnit) }) } } @@ -30,5 +32,5 @@ private fun Any.ignore() = Unit interface ExpectTestScope : CoroutineScope { fun verifyExpects() - fun T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) + fun T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit) } \ No newline at end of file diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle index 60caf38..33b6cfe 100644 --- a/features/messenger/build.gradle +++ b/features/messenger/build.gradle @@ -11,4 +11,13 @@ dependencies { implementation project(":features:navigator") implementation project(":design-library") implementation("io.coil-kt:coil-compose:1.4.0") + + kotlinTest(it) + + androidImportFixturesWorkaround(project, project(":matrix:services:sync")) + androidImportFixturesWorkaround(project, project(":matrix:common")) + androidImportFixturesWorkaround(project, project(":core")) + androidImportFixturesWorkaround(project, project(":domains:store")) + androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) + androidImportFixturesWorkaround(project, project(":domains:android:stub")) } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt new file mode 100644 index 0000000..dbbc3d8 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalEchoMapper.kt @@ -0,0 +1,37 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.message.MessageService +import app.dapk.st.matrix.sync.MessageMeta +import app.dapk.st.matrix.sync.RoomEvent + +class LocalEchoMapper { + + fun MessageService.LocalEcho.toMessage(message: MessageService.Message.TextMessage, member: RoomMember): RoomEvent.Message { + return RoomEvent.Message( + eventId = this.eventId ?: EventId(this.localId), + content = message.content.body, + author = member, + utcTimestamp = message.timestampUtc, + meta = this.toMeta() + ) + } + + fun RoomEvent.mergeWith(echo: MessageService.LocalEcho) = when (this) { + is RoomEvent.Message -> this.copy(meta = echo.toMeta()) + is RoomEvent.Reply -> this.copy(message = this.message.copy(meta = echo.toMeta())) + } + + private fun MessageService.LocalEcho.toMeta() = MessageMeta.LocalEcho( + echoId = this.localId, + state = when (val localEchoState = this.state) { + MessageService.LocalEcho.State.Sending -> MessageMeta.LocalEcho.State.Sending + MessageService.LocalEcho.State.Sent -> MessageMeta.LocalEcho.State.Sent + is MessageService.LocalEcho.State.Error -> MessageMeta.LocalEcho.State.Error( + localEchoState.message, + type = MessageMeta.LocalEcho.State.Error.Type.UNKNOWN, + ) + } + ) +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt new file mode 100644 index 0000000..ecb1777 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt @@ -0,0 +1,7 @@ +package app.dapk.st.messenger + +import java.util.* + +internal class LocalIdFactory { + fun create() = "local.${UUID.randomUUID()}" +} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt index 65cba27..c8a8c7e 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCase.kt @@ -3,63 +3,50 @@ package app.dapk.st.messenger import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.message.MessageService -import app.dapk.st.matrix.sync.MessageMeta import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomState internal typealias MergeWithLocalEchosUseCase = (RoomState, RoomMember, List) -> RoomState -internal class MergeWithLocalEchosUseCaseImpl : MergeWithLocalEchosUseCase { +internal class MergeWithLocalEchosUseCaseImpl( + private val localEventMapper: LocalEchoMapper, +) : MergeWithLocalEchosUseCase { override fun invoke(roomState: RoomState, member: RoomMember, echos: List): RoomState { val echosByEventId = echos.associateBy { it.eventId } val stateByEventId = roomState.events.associateBy { it.eventId } - val uniqueEchos = echos.filter { echo -> - echo.eventId == null || stateByEventId[echo.eventId] == null - }.map { localEcho -> - when (val message = localEcho.message) { - is MessageService.Message.TextMessage -> { - createMessage(localEcho, message, member) - } - } - } + val uniqueEchos = uniqueEchos(echos, stateByEventId, member) + val existingWithEcho = updateExistingEventsWithLocalEchoMeta(roomState, echosByEventId) - val existingWithEcho = roomState.events.map { - when (val echo = echosByEventId[it.eventId]) { - null -> it - else -> when (it) { - is RoomEvent.Message -> it.copy( - meta = echo.toMeta() - ) - is RoomEvent.Reply -> it.copy(message = it.message.copy(meta = echo.toMeta())) - } - } - } val sortedEvents = (existingWithEcho + uniqueEchos) .sortedByDescending { if (it is RoomEvent.Message) it.utcTimestamp else null } .distinctBy { it.eventId } return roomState.copy(events = sortedEvents) } -} - -private fun createMessage(localEcho: MessageService.LocalEcho, message: MessageService.Message.TextMessage, member: RoomMember) = RoomEvent.Message( - eventId = localEcho.eventId ?: EventId(localEcho.localId), - content = message.content.body, - author = member, - utcTimestamp = message.timestampUtc, - meta = localEcho.toMeta() -) - -private fun MessageService.LocalEcho.toMeta() = MessageMeta.LocalEcho( - echoId = this.localId, - state = when (val localEchoState = this.state) { - MessageService.LocalEcho.State.Sending -> MessageMeta.LocalEcho.State.Sending - MessageService.LocalEcho.State.Sent -> MessageMeta.LocalEcho.State.Sent - is MessageService.LocalEcho.State.Error -> MessageMeta.LocalEcho.State.Error( - localEchoState.message, - type = MessageMeta.LocalEcho.State.Error.Type.UNKNOWN, - ) + private fun uniqueEchos(echos: List, stateByEventId: Map, member: RoomMember): List { + return with(localEventMapper) { + echos + .filter { echo -> echo.eventId == null || stateByEventId[echo.eventId] == null } + .map { localEcho -> + when (val message = localEcho.message) { + is MessageService.Message.TextMessage -> { + localEcho.toMessage(message, member) + } + } + } + } } -) + + private fun updateExistingEventsWithLocalEchoMeta(roomState: RoomState, echosByEventId: Map): List { + return with(localEventMapper) { + roomState.events.map { roomEvent -> + when (val echo = echosByEventId[roomEvent.eventId]) { + null -> roomEvent + else -> roomEvent.mergeWith(echo) + } + } + } + } +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index 00da7d8..2a3c98e 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -6,6 +6,7 @@ import app.dapk.st.matrix.message.MessageService import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.matrix.sync.SyncService +import java.time.Clock class MessengerModule( private val syncService: SyncService, @@ -13,11 +14,12 @@ class MessengerModule( private val roomService: RoomService, private val credentialsStore: CredentialsStore, private val roomStore: RoomStore, + private val clock: Clock, ) : ProvidableModule { internal fun messengerViewModel(): MessengerViewModel { - return MessengerViewModel(messageService, roomService, roomStore, credentialsStore, timelineUseCase()) + return MessengerViewModel(messageService, roomService, roomStore, credentialsStore, timelineUseCase(), LocalIdFactory(), clock) } - private fun timelineUseCase() = TimelineUseCase(syncService, messageService, roomService, MergeWithLocalEchosUseCaseImpl()) + private fun timelineUseCase() = TimelineUseCaseImpl(syncService, messageService, roomService, MergeWithLocalEchosUseCaseImpl(LocalEchoMapper())) } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 8d54671..87d457b 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -12,23 +12,30 @@ import app.dapk.st.matrix.room.RoomService import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomStore import app.dapk.st.viewmodel.DapkViewModel +import app.dapk.st.viewmodel.MutableStateFactory +import app.dapk.st.viewmodel.defaultStateFactory import kotlinx.coroutines.* import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onEach +import java.time.Clock internal class MessengerViewModel( private val messageService: MessageService, private val roomService: RoomService, private val roomStore: RoomStore, private val credentialsStore: CredentialsStore, - private val useCase: TimelineUseCase, + private val observeTimeline: ObserveTimelineUseCase, + private val localIdFactory: LocalIdFactory, + private val clock: Clock, + factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = MessengerScreenState( roomId = null, roomState = Lce.Loading(), composerState = ComposerState.Text(value = "") - ) + ), + factory = factory, ) { private var syncJob: Job? = null @@ -49,7 +56,7 @@ internal class MessengerViewModel( val credentials = credentialsStore.credentials()!! var lastKnownReadEvent: EventId? = null - useCase.state(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> + observeTimeline.invoke(action.roomId, credentials.userId).distinctUntilChanged().onEach { state -> state.lastestMessageEventFromOthers(self = credentials.userId)?.let { if (lastKnownReadEvent != it) { updateRoomReadStateAsync(latestReadEvent = it, state) @@ -82,6 +89,8 @@ internal class MessengerViewModel( MessageService.Message.Content.TextContent(body = copy.value), roomId = roomState.roomOverview.roomId, sendEncrypted = roomState.roomOverview.isEncrypted, + localId = localIdFactory.create(), + timestampUtc = clock.millis(), ) ) } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt index 1f64e69..3b51d29 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/TimelineUseCase.kt @@ -10,20 +10,16 @@ import app.dapk.st.matrix.sync.SyncService import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -data class MessengerState( - val self: UserId, - val roomState: RoomState, - val typing: SyncService.SyncEvent.Typing? -) +internal typealias ObserveTimelineUseCase = (RoomId, UserId) -> Flow -internal class TimelineUseCase( +internal class TimelineUseCaseImpl( private val syncService: SyncService, private val messageService: MessageService, private val roomService: RoomService, private val mergeWithLocalEchosUseCase: MergeWithLocalEchosUseCase -) { +) : ObserveTimelineUseCase { - suspend fun state(roomId: RoomId, userId: UserId): Flow { + override fun invoke(roomId: RoomId, userId: UserId): Flow { return combine( syncService.startSyncing(), syncService.room(roomId), @@ -50,3 +46,9 @@ internal class TimelineUseCase( } } + +data class MessengerState( + val self: UserId, + val roomState: RoomState, + val typing: SyncService.SyncEvent.Typing? +) diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/FakeLocalIdFactory.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/FakeLocalIdFactory.kt new file mode 100644 index 0000000..c752519 --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/FakeLocalIdFactory.kt @@ -0,0 +1,10 @@ +package app.dapk.st.messenger + +import io.mockk.every +import io.mockk.mockk +import test.delegateReturn + +internal class FakeLocalIdFactory { + val instance = mockk() + fun givenCreate() = every { instance.create() }.delegateReturn() +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt new file mode 100644 index 0000000..5400b75 --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MergeWithLocalEchosUseCaseTest.kt @@ -0,0 +1,82 @@ +package app.dapk.st.messenger + +import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.MessageType +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.RoomMember +import app.dapk.st.matrix.message.MessageService +import fixture.* +import io.mockk.every +import io.mockk.mockk +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private val A_ROOM_MESSAGE_EVENT = aRoomMessageEvent(eventId = anEventId("1")) +private val A_LOCAL_ECHO_EVENT_ID = anEventId("2") +private const val A_LOCAL_ECHO_BODY = "body" +private val A_ROOM_MEMBER = aRoomMember() +private val ANOTHER_ROOM_MESSAGE_EVENT = A_ROOM_MESSAGE_EVENT.copy(eventId = anEventId("a second room event")) + +class MergeWithLocalEchosUseCaseTest { + + private val fakeLocalEchoMapper = FakeLocalEventMapper() + private val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance) + + @Test + fun `given no local echos, when merging, then returns original state`() { + val roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_EVENT)) + + val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, emptyList()) + + result shouldBeEqualTo roomState + } + + @Test + fun `given local echo with sending state, when merging then maps to room event with local echo state`() { + val second = createLocalEcho(A_LOCAL_ECHO_EVENT_ID, A_LOCAL_ECHO_BODY, state = MessageService.LocalEcho.State.Sending) + fakeLocalEchoMapper.givenMapping(second, aTextMessage(aTextContent(A_LOCAL_ECHO_BODY)), A_ROOM_MEMBER).returns(ANOTHER_ROOM_MESSAGE_EVENT) + val roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_EVENT)) + + val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, listOf(second)) + + result shouldBeEqualTo roomState.copy( + events = listOf( + A_ROOM_MESSAGE_EVENT, + ANOTHER_ROOM_MESSAGE_EVENT, + ) + ) + } + + private fun createLocalEcho(eventId: EventId, body: String, state: MessageService.LocalEcho.State) = aLocalEcho( + eventId, + aTextMessage(aTextContent(body)), + state, + ) +} + +fun aLocalEcho( + eventId: EventId? = anEventId(), + message: MessageService.Message = aTextMessage(), + state: MessageService.LocalEcho.State = MessageService.LocalEcho.State.Sending, +) = MessageService.LocalEcho(eventId, message, state) + + +fun aTextMessage( + content: MessageService.Message.Content.TextContent = aTextContent(), + sendEncrypted: Boolean = false, + roomId: RoomId = aRoomId(), + localId: String = "a-local-id", + timestampUtc: Long = 0, +) = MessageService.Message.TextMessage(content, sendEncrypted, roomId, localId, timestampUtc) + +fun aTextContent( + body: String = "text content body", + type: String = MessageType.TEXT.value, +) = MessageService.Message.Content.TextContent(body, type) + +class FakeLocalEventMapper { + val instance = mockk() + fun givenMapping(echo: MessageService.LocalEcho, event: MessageService.Message.TextMessage, roomMember: RoomMember) = every { + with(instance) { echo.toMessage(event, roomMember) } + } +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt new file mode 100644 index 0000000..34f08ec --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -0,0 +1,141 @@ +package app.dapk.st.messenger + +import ViewModelTest +import app.dapk.st.core.Lce +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.matrix.message.MessageService +import app.dapk.st.matrix.room.RoomService +import app.dapk.st.matrix.sync.RoomState +import fake.FakeCredentialsStore +import fake.FakeRoomStore +import fixture.* +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import test.delegateReturn +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +private const val A_CURRENT_TIMESTAMP = 10000L +private val A_ROOM_ID = aRoomId("messenger state room id") +private const val A_MESSAGE_CONTENT = "message content" +private const val A_LOCAL_ID = "local.1111-2222-3333" +private val AN_EVENT_ID = anEventId("state event") +private val A_SELF_ID = aUserId("self") + +class MessengerViewModelTest { + + private val runViewModelTest = ViewModelTest() + + private val fakeMessageService = FakeMessageService() + private val fakeRoomService = FakeRoomService() + private val fakeRoomStore = FakeRoomStore() + private val fakeCredentialsStore = FakeCredentialsStore().also { it.givenCredentials().returns(aUserCredentials(userId = A_SELF_ID)) } + private val fakeObserveTimelineUseCase = FakeObserveTimelineUseCase() + + private val viewModel = MessengerViewModel( + fakeMessageService, + fakeRoomService, + fakeRoomStore, + fakeCredentialsStore, + fakeObserveTimelineUseCase, + localIdFactory = FakeLocalIdFactory().also { it.givenCreate().returns(A_LOCAL_ID) }.instance, + clock = fixedClock(A_CURRENT_TIMESTAMP), + 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 = "") + ) + ) + } + + @Test + fun `given timeline emits state, when starting, then updates state and marks room and events as read`() = runViewModelTest { + fakeRoomStore.expectUnit(times = 2) { it.markRead(A_ROOM_ID) } + fakeRoomService.expectUnit { it.markFullyRead(A_ROOM_ID, AN_EVENT_ID) } + val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) + fakeObserveTimelineUseCase.given(A_ROOM_ID, A_SELF_ID).returns(flowOf(state)) + + viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID)) + + assertStates( + { 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({ + copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT)) + }) + } + + @Test + fun `given composer message state when posting send text, then resets composer state and sends message`() = runViewModelTest { + fakeMessageService.expectUnit { it.scheduleMessage(expectEncryptedMessage(A_ROOM_ID, A_LOCAL_ID, A_CURRENT_TIMESTAMP, A_MESSAGE_CONTENT)) } + + viewModel.test(initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT)).post(MessengerAction.ComposerSendText) + + assertStates({ copy(composerState = ComposerState.Text("")) }) + 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 expectEncryptedMessage(roomId: RoomId, localId: String, timestamp: Long, messageContent: String): MessageService.Message.TextMessage { + val content = MessageService.Message.Content.TextContent(body = messageContent) + return MessageService.Message.TextMessage(content, sendEncrypted = true, roomId, localId, timestamp) + } + + 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: MessengerState, messageContent: String?) = MessengerScreenState( + roomId = roomId, + roomState = Lce.Content(roomState), + composerState = ComposerState.Text(value = messageContent ?: "") +) + +fun aMessengerState( + self: UserId = aUserId(), + roomState: RoomState, +) = MessengerState(self, roomState, typing = null) + +class FakeObserveTimelineUseCase : ObserveTimelineUseCase by mockk() { + fun given(roomId: RoomId, selfId: UserId) = coEvery { this@FakeObserveTimelineUseCase.invoke(roomId, selfId) }.delegateReturn() +} + +class FakeMessageService : MessageService by mockk() +class FakeRoomService : RoomService by mockk() + +fun fixedClock(timestamp: Long = 0) = Clock.fixed(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC) diff --git a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt index e49360a..1f8860d 100644 --- a/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt +++ b/matrix/services/message/src/main/kotlin/app/dapk/st/matrix/message/MessageService.kt @@ -42,8 +42,8 @@ interface MessageService : MatrixService { @SerialName("content") val content: Content.TextContent, @SerialName("send_encrypted") val sendEncrypted: Boolean, @SerialName("room_id") val roomId: RoomId, - @SerialName("local_id") val localId: String = "local.${UUID.randomUUID()}", - @SerialName("timestamp") val timestampUtc: Long = System.currentTimeMillis(), + @SerialName("local_id") val localId: String, + @SerialName("timestamp") val timestampUtc: Long, ) : Message() @Serializable diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt index 927ac39..d614d8f 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/SyncService.kt @@ -21,9 +21,9 @@ interface SyncService : MatrixService { suspend fun invites(): Flow suspend fun overview(): Flow - suspend fun room(roomId: RoomId): Flow + fun room(roomId: RoomId): Flow fun startSyncing(): Flow - suspend fun events(): Flow> + fun events(): Flow> suspend fun observeEvent(eventId: EventId): Flow suspend fun forceManualRefresh(roomIds: List) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt index dacfb21..0c0f38c 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/DefaultSyncService.kt @@ -101,8 +101,8 @@ internal class DefaultSyncService( override fun startSyncing() = syncFlow override suspend fun invites() = overviewStore.latestInvites() override suspend fun overview() = overviewStore.latest() - override suspend fun room(roomId: RoomId) = roomStore.latest(roomId) - override suspend fun events() = syncEventsFlow + override fun room(roomId: RoomId) = roomStore.latest(roomId) + override fun events() = syncEventsFlow override suspend fun observeEvent(eventId: EventId) = roomStore.observeEvent(eventId) override suspend fun forceManualRefresh(roomIds: List) { withContext(Dispatchers.IO) { diff --git a/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt new file mode 100644 index 0000000..260e46d --- /dev/null +++ b/matrix/services/sync/src/testFixtures/kotlin/fixture/RoomStateFixture.kt @@ -0,0 +1,10 @@ +package fixture + +import app.dapk.st.matrix.sync.RoomEvent +import app.dapk.st.matrix.sync.RoomOverview +import app.dapk.st.matrix.sync.RoomState + +fun aRoomState( + roomOverview: RoomOverview = aRoomOverview(), + events: List = listOf(aRoomMessageEvent()), +) = RoomState(roomOverview, events) \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/Test.kt b/test-harness/src/test/kotlin/test/Test.kt index f712d70..8ac1f69 100644 --- a/test-harness/src/test/kotlin/test/Test.kt +++ b/test-harness/src/test/kotlin/test/Test.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.amshove.kluent.fail import org.amshove.kluent.shouldBeEqualTo +import java.util.* fun flowTest(block: suspend MatrixTestScope.() -> Unit) { runTest { @@ -129,6 +130,8 @@ class MatrixTestScope(private val testScope: TestScope) { content = MessageService.Message.Content.TextContent(body = content), roomId = roomId, sendEncrypted = true, + localId = "local.${UUID.randomUUID()}", + timestampUtc = System.currentTimeMillis(), ) ) }