From c005d8aeba1f32388e1f27dd87d1e2aa52ad8741 Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 17 Apr 2025 19:59:52 +0100 Subject: [PATCH 1/4] #150: wip --- src/main/kotlin/chatserver/ChatServer.kt | 2 + .../chatserver/profiles/ChatServerProfiles.kt | 18 +++++ .../profiles/LocalProfilesRepository.kt | 68 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt create mode 100644 src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt diff --git a/src/main/kotlin/chatserver/ChatServer.kt b/src/main/kotlin/chatserver/ChatServer.kt index 4280b03..78afe87 100644 --- a/src/main/kotlin/chatserver/ChatServer.kt +++ b/src/main/kotlin/chatserver/ChatServer.kt @@ -2,10 +2,12 @@ package chatserver import chatserver.MessagesRepository.Read import chatserver.MessagesRepository.Write +import chatserver.profiles.chatServerProfilesModule import org.koin.dsl.binds import org.koin.dsl.module val chatServerModule = module { + includes(chatServerProfilesModule) factory { MockMessages } binds arrayOf(Read::class, Write::class) } diff --git a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt new file mode 100644 index 0000000..1b3816d --- /dev/null +++ b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt @@ -0,0 +1,18 @@ +package chatserver.profiles + +import arch.RokyDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.koin.dsl.module + +val chatServerProfilesModule = + module { + single { + LocalProfilesRepository( + get(), + CoroutineScope( + SupervisorJob() + get().default, + ), + ) + } + } diff --git a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt new file mode 100644 index 0000000..ad98b03 --- /dev/null +++ b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt @@ -0,0 +1,68 @@ +package chatserver.profiles + +import arch.RokyDispatchers +import chatroom.viewmessages.ViewMessagesUseCase +import chatserver.ProfileResult +import chatserver.ProfileResult.Companion.ok +import chatserver.ReadChatRepository +import chatserver.SubscribeChatRepository +import chatserver.WriteChatRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +class LocalProfilesRepository( + private val dispatchers: RokyDispatchers, + private val scope: CoroutineScope, +) : ReadChatRepository, WriteChatRepository, SubscribeChatRepository { + private val _state: MutableStateFlow = MutableStateFlow(ok(emptyMap())) + + override fun latest(): ProfileResult { + return _state.value + } + + override fun observe(): Flow { + return _state.asStateFlow() + } + + override fun subscribe() { + scope.launch(dispatchers.default) { + while (true) { + val users = ViewMessagesUseCase.sampleUsers.shuffled() + _state.value = users.associateWith { it }.let(ProfileResult::ok) + } + } + } + + override fun unsubscribe() { + } + + override fun write(userName: String) { + scope.launch { + try { + if (userName.isBlank()) { + throw IllegalArgumentException("Username cannot be empty.") + } + delay(2.seconds) + if (!userName.isValidUsername()) { + throw IllegalStateException("Could not assign current username.") + } + val profiles = latest().item.toMutableMap() + profiles.put(userName, userName) + _state.value = ok(profiles) + } catch (e: Exception) { + _state.value = ProfileResult.fail(e) + } + } + } + + companion object { + private const val VALID_LENGTH = 6 + + private fun String.isValidUsername(): Boolean = length < VALID_LENGTH + } +} From bb6af0889841299eeb8388594eb0ea4745a29ff1 Mon Sep 17 00:00:00 2001 From: Robert Davie Date: Sun, 27 Apr 2025 17:17:45 +0100 Subject: [PATCH 2/4] #150: add local profiles repository to dependency injection --- .../chatserver/profiles/ChatServerProfiles.kt | 13 ++++++++++ .../profiles/LocalProfilesRepository.kt | 26 +++++++++---------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt index 1b3816d..311caee 100644 --- a/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt +++ b/src/main/kotlin/chatserver/profiles/ChatServerProfiles.kt @@ -1,6 +1,10 @@ package chatserver.profiles import arch.RokyDispatchers +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.SubscribeChatRepository +import chatserver.WriteChatRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import org.koin.dsl.module @@ -15,4 +19,13 @@ val chatServerProfilesModule = ), ) } + factory> { + get() + } + factory> { + get() + } + factory { + get() + } } diff --git a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt index ad98b03..a977863 100644 --- a/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt +++ b/src/main/kotlin/chatserver/profiles/LocalProfilesRepository.kt @@ -19,43 +19,41 @@ class LocalProfilesRepository( private val dispatchers: RokyDispatchers, private val scope: CoroutineScope, ) : ReadChatRepository, WriteChatRepository, SubscribeChatRepository { - private val _state: MutableStateFlow = MutableStateFlow(ok(emptyMap())) + private val state: MutableStateFlow = MutableStateFlow(ok(emptyMap())) override fun latest(): ProfileResult { - return _state.value + return state.value } override fun observe(): Flow { - return _state.asStateFlow() + return state.asStateFlow() } override fun subscribe() { scope.launch(dispatchers.default) { while (true) { val users = ViewMessagesUseCase.sampleUsers.shuffled() - _state.value = users.associateWith { it }.let(ProfileResult::ok) + state.value = users.associateWith { it }.let(ProfileResult::ok) + delay(5.seconds) } } } override fun unsubscribe() { + // deliberately empty } - override fun write(userName: String) { + override fun write(item: String) { scope.launch { try { - if (userName.isBlank()) { - throw IllegalArgumentException("Username cannot be empty.") - } + require(item.isNotBlank()) { "Username cannot be empty." } delay(2.seconds) - if (!userName.isValidUsername()) { - throw IllegalStateException("Could not assign current username.") - } + check(item.isValidUsername()) { "Could not assign current username." } val profiles = latest().item.toMutableMap() - profiles.put(userName, userName) - _state.value = ok(profiles) + profiles[item] = item + state.value = ok(profiles) } catch (e: Exception) { - _state.value = ProfileResult.fail(e) + state.value = ProfileResult.fail(e) } } } From 54d51bf855e47c699992ac9a4192b6c4356c812c Mon Sep 17 00:00:00 2001 From: KaiRaiChu Date: Wed, 30 Apr 2025 17:54:30 +0100 Subject: [PATCH 3/4] #150: Begin integrating local profiles into UI --- gradle.properties | 2 +- src/main/kotlin/profile/Profile.kt | 4 +- src/main/kotlin/profile/ProfilePresenter.kt | 20 ++++++++-- .../kotlin/profile/RequestUserNameUseCase.kt | 38 ------------------- .../kotlin/profile/RequestUsernameUseCase.kt | 12 ++++++ 5 files changed, 31 insertions(+), 45 deletions(-) delete mode 100644 src/main/kotlin/profile/RequestUserNameUseCase.kt create mode 100644 src/main/kotlin/profile/RequestUsernameUseCase.kt diff --git a/gradle.properties b/gradle.properties index 028b076..c3052f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official # Determines whether this project should use hardcoded mocks or Supabase-CLI. # See https://github.com/PPartisan/Roky/wiki/Project-Setup -USE_LOCAL_MOCKS=false +USE_LOCAL_MOCKS=true diff --git a/src/main/kotlin/profile/Profile.kt b/src/main/kotlin/profile/Profile.kt index 7f46cf5..c0a3587 100644 --- a/src/main/kotlin/profile/Profile.kt +++ b/src/main/kotlin/profile/Profile.kt @@ -9,10 +9,10 @@ val profileModules = ProfilePresenter( windowScope = get().windowScope, requestUsername = get(), + usernames = get(), dispatchers = get(), ) } - scoped { RequestUserNameUseCase(get()) } + scoped { RequestUsernameUseCase(get()) } } - factory { RequestUserNameUseCase.RequestUserName() } } diff --git a/src/main/kotlin/profile/ProfilePresenter.kt b/src/main/kotlin/profile/ProfilePresenter.kt index d3e14fb..7feb532 100644 --- a/src/main/kotlin/profile/ProfilePresenter.kt +++ b/src/main/kotlin/profile/ProfilePresenter.kt @@ -2,16 +2,20 @@ package profile import arch.Presenter import arch.RokyDispatchers +import chatserver.ProfileResult +import chatserver.ReadChatRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import profile.ProfileEvent.RequestUsername -import profile.ProfileViewState.Idle -import profile.ProfileViewState.Pending +import profile.ProfileViewState.* class ProfilePresenter( private val windowScope: CoroutineScope, - private val requestUsername: RequestUserNameUseCase, + private val requestUsername: RequestUsernameUseCase, + private val usernames: ReadChatRepository, dispatchers: RokyDispatchers, ) : Presenter(dispatchers) { private val state: MutableStateFlow = MutableStateFlow(Idle) @@ -20,6 +24,9 @@ class ProfilePresenter( windowScope.launch(dispatchers.main) { state.collect(::show) } + windowScope.launch(dispatchers.io) { + usernames.observe().drop(1).map {it.toViewState()}.collect{state.value=it} + } } override fun onDetach(view: ProfileView) { @@ -35,11 +42,16 @@ class ProfilePresenter( private fun onRequestUsername(event: RequestUsername) { state.value = Pending windowScope.launch(dispatchers.io) { - state.value = requestUsername(event.username) + requestUsername(event.username) } } private fun show(viewState: ProfileViewState) { withView { view -> view.show(viewState) } } + + private fun ProfileResult.toViewState(): ProfileViewState = + if (isOk) Success("Successfully changed username!") + else Failed(error?.message ?: "Unsuccessful!") + } diff --git a/src/main/kotlin/profile/RequestUserNameUseCase.kt b/src/main/kotlin/profile/RequestUserNameUseCase.kt deleted file mode 100644 index 9866a17..0000000 --- a/src/main/kotlin/profile/RequestUserNameUseCase.kt +++ /dev/null @@ -1,38 +0,0 @@ -package profile - -import kotlinx.coroutines.delay -import profile.ProfileViewState.Failed -import profile.ProfileViewState.Success -import kotlin.time.Duration.Companion.seconds - -class RequestUserNameUseCase( - private val request: RequestUserName, -) { - suspend operator fun invoke(username: String): ProfileViewState { - if (username.isBlank()) { - return Failed(ERROR_USERNAME_BLANK) - } - return with(username) { - if (request(this)) toSuccess() else toFailed() - } - } - - companion object { - const val ERROR_USERNAME_BLANK = "Username cannot be blank." - - private fun String.toSuccess(): ProfileViewState = Success("Changed username to $this") - - private fun String.toFailed(): ProfileViewState = Failed("Could not change username to\n$this") - } - - class RequestUserName { - suspend operator fun invoke(username: String): Boolean { - delay(2.seconds) - return username.length < VALID_LENGTH - } - - companion object { - private const val VALID_LENGTH = 6 - } - } -} diff --git a/src/main/kotlin/profile/RequestUsernameUseCase.kt b/src/main/kotlin/profile/RequestUsernameUseCase.kt new file mode 100644 index 0000000..6908ad4 --- /dev/null +++ b/src/main/kotlin/profile/RequestUsernameUseCase.kt @@ -0,0 +1,12 @@ +package profile + +import chatserver.WriteChatRepository + +class RequestUsernameUseCase( + private val profiles: WriteChatRepository +) { + operator fun invoke(username: String) { + profiles.write(username) + } +} + From adf1b2cb7f51410f5182f33bb604e227031b4479 Mon Sep 17 00:00:00 2001 From: KaiRaiChu Date: Tue, 13 May 2025 20:09:39 +0100 Subject: [PATCH 4/4] #150: Update profile presenter test to use new chat server abstractions --- src/main/kotlin/profile/Profile.kt | 1 - src/main/kotlin/profile/ProfilePresenter.kt | 19 +++-- .../kotlin/profile/RequestUsernameUseCase.kt | 12 ---- .../kotlin/profile/ProfilePresenterTest.kt | 44 ++++++++---- .../profile/RequestUserNameUseCaseTest.kt | 70 ------------------- 5 files changed, 46 insertions(+), 100 deletions(-) delete mode 100644 src/main/kotlin/profile/RequestUsernameUseCase.kt delete mode 100644 src/test/kotlin/profile/RequestUserNameUseCaseTest.kt diff --git a/src/main/kotlin/profile/Profile.kt b/src/main/kotlin/profile/Profile.kt index c0a3587..577e637 100644 --- a/src/main/kotlin/profile/Profile.kt +++ b/src/main/kotlin/profile/Profile.kt @@ -13,6 +13,5 @@ val profileModules = dispatchers = get(), ) } - scoped { RequestUsernameUseCase(get()) } } } diff --git a/src/main/kotlin/profile/ProfilePresenter.kt b/src/main/kotlin/profile/ProfilePresenter.kt index 7feb532..a4503f5 100644 --- a/src/main/kotlin/profile/ProfilePresenter.kt +++ b/src/main/kotlin/profile/ProfilePresenter.kt @@ -4,17 +4,19 @@ import arch.Presenter import arch.RokyDispatchers import chatserver.ProfileResult import chatserver.ReadChatRepository +import chatserver.WriteChatRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import profile.ProfileEvent.RequestUsername import profile.ProfileViewState.* class ProfilePresenter( private val windowScope: CoroutineScope, - private val requestUsername: RequestUsernameUseCase, + private val requestUsername: WriteChatRepository, private val usernames: ReadChatRepository, dispatchers: RokyDispatchers, ) : Presenter(dispatchers) { @@ -25,7 +27,7 @@ class ProfilePresenter( state.collect(::show) } windowScope.launch(dispatchers.io) { - usernames.observe().drop(1).map {it.toViewState()}.collect{state.value=it} + usernames.observe().drop(1).map { it.toViewState() }.collect { state.value = it } } } @@ -42,7 +44,7 @@ class ProfilePresenter( private fun onRequestUsername(event: RequestUsername) { state.value = Pending windowScope.launch(dispatchers.io) { - requestUsername(event.username) + requestUsername.write(event.username) } } @@ -51,7 +53,14 @@ class ProfilePresenter( } private fun ProfileResult.toViewState(): ProfileViewState = - if (isOk) Success("Successfully changed username!") - else Failed(error?.message ?: "Unsuccessful!") + if (isOk) { + Success(MESSAGE_OK) + } else { + Failed(error?.message ?: "Unsuccessful!") + } + companion object { + @VisibleForTesting + internal const val MESSAGE_OK = "Successfully changed username!" + } } diff --git a/src/main/kotlin/profile/RequestUsernameUseCase.kt b/src/main/kotlin/profile/RequestUsernameUseCase.kt deleted file mode 100644 index 6908ad4..0000000 --- a/src/main/kotlin/profile/RequestUsernameUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package profile - -import chatserver.WriteChatRepository - -class RequestUsernameUseCase( - private val profiles: WriteChatRepository -) { - operator fun invoke(username: String) { - profiles.write(username) - } -} - diff --git a/src/test/kotlin/profile/ProfilePresenterTest.kt b/src/test/kotlin/profile/ProfilePresenterTest.kt index 0b4892c..4c33f01 100644 --- a/src/test/kotlin/profile/ProfilePresenterTest.kt +++ b/src/test/kotlin/profile/ProfilePresenterTest.kt @@ -1,44 +1,65 @@ package profile import arch.RokyDispatchers -import coAnswersDelayed -import io.mockk.* +import chatserver.ProfileResult +import chatserver.ReadChatRepository +import chatserver.WriteChatRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import profile.ProfilePresenter.Companion.MESSAGE_OK import profile.ProfileViewState.* @OptIn(ExperimentalCoroutinesApi::class) class ProfilePresenterTest { private lateinit var scope: CoroutineScope private lateinit var view: ProfileView - private lateinit var requestUsername: RequestUserNameUseCase + private lateinit var usernames: MutableStateFlow private lateinit var presenter: ProfilePresenter @BeforeEach fun setUp() { - requestUsername = mockk() - coEvery { requestUsername(any()) } coAnswersDelayed { Idle } - view = mockk(relaxed = true) val dispatchers: RokyDispatchers = mockk().apply { every { main } returns dispatcher every { io } returns dispatcher } + usernames = MutableStateFlow(ProfileResult.ok(emptyMap())) + val usernameRepository: ReadChatRepository = mockk() + every { usernameRepository.observe() } returns usernames + every { usernameRepository.latest() } answers { + usernames.value + } + val writeUsername: WriteChatRepository = mockk() + every { writeUsername.write(any()) } answers { + val user = it.invocation.args [0] as String + usernames.value = + if (user == INVALID_USER) { + ProfileResult.fail(RuntimeException()) + } else { + val currentUsernames = usernames.value.item.toMutableMap() + currentUsernames[user] = user + ProfileResult.ok(currentUsernames) + } + } scope = CoroutineScope(dispatcher) - presenter = ProfilePresenter(scope, requestUsername, dispatchers) + presenter = ProfilePresenter(scope, writeUsername, usernameRepository, dispatchers) } @Test fun `given user requests new usernames, when request is successful, then show success status`() = runTest(dispatcher) { - coEvery { requestUsername(any()) } coAnswersDelayed { Success("OKAYYY") } presenter.attach(view) advanceUntilIdle() presenter.onEvent(ProfileEvent.RequestUsername("USERNAMEE")) @@ -49,7 +70,7 @@ class ProfilePresenterTest { view.show( withArg { assertTrue(it is Success) - assertTrue(it.status == "OKAYYY") + assertTrue(it.status == MESSAGE_OK) }, ) } @@ -58,10 +79,9 @@ class ProfilePresenterTest { @Test fun `given user requests new username, when request fails, then show failed status`() = runTest(dispatcher) { - coEvery { requestUsername(any()) } coAnswersDelayed { Failed("!OKAYYY") } presenter.attach(view) advanceUntilIdle() - presenter.onEvent(ProfileEvent.RequestUsername("USERNAMEE")) + presenter.onEvent(ProfileEvent.RequestUsername(INVALID_USER)) advanceUntilIdle() verifyOrder { view.show(Idle) @@ -69,7 +89,6 @@ class ProfilePresenterTest { view.show( withArg { assertTrue(it is Failed) - assertTrue(it.status == "!OKAYYY") }, ) } @@ -85,5 +104,6 @@ class ProfilePresenterTest { companion object { private val dispatcher = StandardTestDispatcher() + private const val INVALID_USER = "INVALID USER!" } } diff --git a/src/test/kotlin/profile/RequestUserNameUseCaseTest.kt b/src/test/kotlin/profile/RequestUserNameUseCaseTest.kt deleted file mode 100644 index df97521..0000000 --- a/src/test/kotlin/profile/RequestUserNameUseCaseTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package profile - -import coAnswersDelayed -import io.kotest.matchers.equals.shouldBeEqual -import io.kotest.matchers.string.shouldStartWith -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import profile.ProfileViewState.Failed -import profile.ProfileViewState.Success -import profile.RequestUserNameUseCase.Companion.ERROR_USERNAME_BLANK -import profile.RequestUserNameUseCase.RequestUserName - -class RequestUserNameUseCaseTest { - private lateinit var requestUserName: RequestUserName - private lateinit var useCase: RequestUserNameUseCase - - @BeforeEach - fun setUp() { - requestUserName = mockk() - coEvery { requestUserName(any()) } coAnswersDelayed { - firstArg() == VALID_USER - } - useCase = RequestUserNameUseCase(requestUserName) - } - - @Test - fun `when username is empty, then request username failed`() = - runTest { - assertTrue(useCase("") is Failed) - } - - @Test - fun `when username is empty, then show username cannot be blank message`() = - runTest { - useCase("").status shouldBeEqual ERROR_USERNAME_BLANK - } - - @Test - fun `when username is invalid, then request username failed`() = - runTest { - assertTrue(useCase(INVALID_USER) is Failed) - } - - @Test - fun `when username is invalid, then show username invalid message`() = - runTest { - useCase(INVALID_USER).status shouldStartWith "Could not change username" - } - - @Test - fun `when username is valid, then request username success`() = - runTest { - assertTrue(useCase(VALID_USER) is Success) - } - - @Test - fun `when username is valid, then show username changed message`() = - runTest { - useCase(VALID_USER).status shouldStartWith "Changed username to" - } - - companion object { - private const val VALID_USER = "valid_user" - private const val INVALID_USER = "invalid_user" - } -}