From 071b8732ae43723624054a5810bc0cb63df3dcc1 Mon Sep 17 00:00:00 2001 From: Niamh Date: Thu, 4 Dec 2025 17:57:52 +0000 Subject: [PATCH 1/3] #160: Move UsersList to repository abstraction --- src/main/kotlin/chatroom/users/Users.kt | 2 +- .../chatroom/users/UsersListPresenter.kt | 30 ++++++--- .../kotlin/chatserver/ChatRepositories.kt | 6 ++ src/main/kotlin/chatserver/ChatServer.kt | 3 +- src/main/kotlin/chatserver/PresenceResult.kt | 8 +-- .../chatserver/presence/ChatServerPresence.kt | 25 ++++++++ .../presence/LocalPresenceRepository.kt | 42 ++++++++++++ .../chatroom/users/UsersListPresenterTest.kt | 64 +++++++++++-------- 8 files changed, 136 insertions(+), 44 deletions(-) create mode 100644 src/main/kotlin/chatserver/presence/ChatServerPresence.kt create mode 100644 src/main/kotlin/chatserver/presence/LocalPresenceRepository.kt diff --git a/src/main/kotlin/chatroom/users/Users.kt b/src/main/kotlin/chatroom/users/Users.kt index 460bbbe..28ec278 100644 --- a/src/main/kotlin/chatroom/users/Users.kt +++ b/src/main/kotlin/chatroom/users/Users.kt @@ -11,8 +11,8 @@ val usersModule = scoped { UsersListUseCase() } scoped { UsersListPresenter( - users = get(), truncate = get(), + repository = get(), scope = get().windowScope, dispatchers = get(), ) diff --git a/src/main/kotlin/chatroom/users/UsersListPresenter.kt b/src/main/kotlin/chatroom/users/UsersListPresenter.kt index c74726c..b96ecdb 100644 --- a/src/main/kotlin/chatroom/users/UsersListPresenter.kt +++ b/src/main/kotlin/chatroom/users/UsersListPresenter.kt @@ -3,29 +3,38 @@ package chatroom.users import arch.Presenter import arch.RokyDispatchers import chatroom.users.UsersViewState.* +import chatserver.ChatRepositories import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class UsersListPresenter( - private val users: UsersListUseCase, private val truncate: UsersListTruncation, + private val repository: ChatRepositories, private val scope: CoroutineScope, dispatchers: RokyDispatchers, ) : Presenter(dispatchers) { + private var job: Job? = null + override fun onAttach(view: UsersListView) { view.show(Empty) - scope.launch(dispatchers.io) { - users() - .truncateUsernames() - .map(::Users).collect { state -> - withContext(dispatchers.main) { - show(state) + + job = + scope.launch(dispatchers.io) { + repository.subscribePresence().subscribe() + repository.readPresence().observe() + .map { it.item.toList() } + .truncateUsernames() + .map(::Users) + .collect { state -> + withContext(dispatchers.main) { + show(state) + } } - } - } + } } private fun show(viewState: UsersViewState) { @@ -33,7 +42,8 @@ class UsersListPresenter( } override fun onDetach(view: UsersListView) { - // Deliberately empty + repository.subscribePresence().unsubscribe() + job?.cancel() } private fun Flow>.truncateUsernames() = diff --git a/src/main/kotlin/chatserver/ChatRepositories.kt b/src/main/kotlin/chatserver/ChatRepositories.kt index 4d1ab04..06f347d 100644 --- a/src/main/kotlin/chatserver/ChatRepositories.kt +++ b/src/main/kotlin/chatserver/ChatRepositories.kt @@ -1,11 +1,13 @@ package chatserver import chatserver.messages.LocalChatMessages +import chatserver.presence.LocalPresenceRepository import chatserver.profiles.LocalProfilesRepository class ChatRepositories( private val profiles: LocalProfilesRepository, private val messages: LocalChatMessages, + private val presence: LocalPresenceRepository, ) { fun writeProfiles(): WriteChatRepository = profiles @@ -18,4 +20,8 @@ class ChatRepositories( fun subscribeMessages(): SubscribeChatRepository = messages fun writeMessages(): WriteChatRepository = messages + + fun subscribePresence(): SubscribeChatRepository = presence + + fun readPresence(): ReadChatRepository = presence } diff --git a/src/main/kotlin/chatserver/ChatServer.kt b/src/main/kotlin/chatserver/ChatServer.kt index f649021..2dfc874 100644 --- a/src/main/kotlin/chatserver/ChatServer.kt +++ b/src/main/kotlin/chatserver/ChatServer.kt @@ -1,12 +1,13 @@ package chatserver import chatserver.messages.chatServerMessagesModule +import chatserver.presence.chatServerPresenceModule import chatserver.profiles.chatServerProfilesModule import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module val chatServerModule = module { - includes(chatServerProfilesModule, chatServerMessagesModule) + includes(chatServerProfilesModule, chatServerMessagesModule, chatServerPresenceModule) factoryOf(::ChatRepositories) } diff --git a/src/main/kotlin/chatserver/PresenceResult.kt b/src/main/kotlin/chatserver/PresenceResult.kt index 048bf07..819f836 100644 --- a/src/main/kotlin/chatserver/PresenceResult.kt +++ b/src/main/kotlin/chatserver/PresenceResult.kt @@ -4,13 +4,13 @@ import chatserver.ReadChatRepository.ReadResult data class PresenceResult( override val isOk: Boolean, - override val item: Map, + override val item: Set, override val error: Exception?, ) : - ReadResult> { + ReadResult> { companion object { - fun ok(item: Map): PresenceResult = PresenceResult(true, item, null) + fun ok(item: Set): PresenceResult = PresenceResult(true, item, null) - fun fail(e: Exception? = null): PresenceResult = PresenceResult(false, emptyMap(), e) + fun fail(e: Exception? = null): PresenceResult = PresenceResult(false, emptySet(), e) } } diff --git a/src/main/kotlin/chatserver/presence/ChatServerPresence.kt b/src/main/kotlin/chatserver/presence/ChatServerPresence.kt new file mode 100644 index 0000000..816c53a --- /dev/null +++ b/src/main/kotlin/chatserver/presence/ChatServerPresence.kt @@ -0,0 +1,25 @@ +package chatserver.presence + +import arch.RokyDispatchers +import chatserver.messages.LocalChatMessages.Companion.sampleUsers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.koin.dsl.module + +val chatServerPresenceModule = + module { + single { + LocalPresenceRepository( + scope = + CoroutineScope( + SupervisorJob() + get().default, + ), + dispatchers = get(), + users = { + with(sampleUsers) { + shuffled().subList(0, (4.. Set, + private val scope: CoroutineScope, + private val dispatchers: RokyDispatchers, +) : SubscribeChatRepository, ReadChatRepository { + private val state: MutableStateFlow = MutableStateFlow(PresenceResult.ok(emptySet())) + private var job: Job? = null + + override fun subscribe() { + unsubscribe() + job = + scope.launch(dispatchers.default) { + while (true) { + state.value = PresenceResult.ok(users()) + delay(10.seconds) + } + } + } + + override fun unsubscribe() { + job?.cancel() + } + + override fun latest(): PresenceResult = state.value + + override fun observe(): Flow = state.asStateFlow() +} diff --git a/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt b/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt index 54c465c..0e5f9c1 100644 --- a/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt +++ b/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt @@ -1,39 +1,47 @@ -package chatroom.users + package chatroom.users -import arch.RokyDispatchers -import chatroom.users.UsersViewState.Empty -import chatroom.users.UsersViewState.Users -import coAnswersDelayed -import io.mockk.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test + import arch.RokyDispatchers + import chatroom.users.UsersViewState.Empty + import chatroom.users.UsersViewState.Users + import chatserver.* + import coAnswersDelayed + import io.mockk.* + import kotlinx.coroutines.CoroutineScope + import kotlinx.coroutines.ExperimentalCoroutinesApi + import kotlinx.coroutines.flow.flowOf + import kotlinx.coroutines.test.StandardTestDispatcher + import kotlinx.coroutines.test.advanceUntilIdle + import kotlinx.coroutines.test.runTest + import org.junit.jupiter.api.Assertions.* + import org.junit.jupiter.api.BeforeEach + import org.junit.jupiter.api.Test -@OptIn(ExperimentalCoroutinesApi::class) -class UsersListPresenterTest { + @OptIn(ExperimentalCoroutinesApi::class) + class UsersListPresenterTest { private lateinit var scope: CoroutineScope - private lateinit var listUsers: UsersListUseCase + private lateinit var subscribePresence: SubscribeChatRepository + private lateinit var readPresence: ReadChatRepository private lateinit var view: UsersListView private lateinit var presenter: UsersListPresenter @BeforeEach fun setUp() { view = mockk(relaxed = true) - listUsers = mockk(relaxed = true) - coEvery { listUsers() } coAnswersDelayed { flowOf() } + val repositories: ChatRepositories = mockk(relaxed = true) + subscribePresence = mockk() + readPresence = mockk() + every { repositories.subscribePresence() } returns subscribePresence + every { subscribePresence.subscribe() } just runs + every { subscribePresence.unsubscribe() } just runs + every { repositories.readPresence() } returns readPresence + coEvery { readPresence.observe() } coAnswersDelayed {flowOf()} val dispatchers: RokyDispatchers = mockk().apply { every { main } returns dispatcher every { io } returns dispatcher } scope = CoroutineScope(dispatcher) - presenter = UsersListPresenter(listUsers, UsersListTruncation(10), scope, dispatchers) + presenter = UsersListPresenter(UsersListTruncation(10), repositories, scope, dispatchers) } @Test @@ -46,7 +54,7 @@ class UsersListPresenterTest { @Test fun `when users list is empty, then show empty list of users`() = runTest(dispatcher) { - coEvery { listUsers() } coAnswersDelayed { flowOf(emptyList()) } + coEvery { readPresence.observe() } coAnswersDelayed { flowOf(PresenceResult.ok(emptySet())) } presenter.attach(view) advanceUntilIdle() verifyOrder { @@ -63,8 +71,8 @@ class UsersListPresenterTest { @Test fun `when users list is not empty, then show list of users`() = runTest(dispatcher) { - val userList = listOf("Robert", "Tom", "Allie", "Kai") - coEvery { listUsers() } coAnswersDelayed { flowOf(userList) } + val userSet = setOf("Robert", "Tom", "Allie", "Kai") + coEvery { readPresence.observe() } coAnswersDelayed { flowOf(PresenceResult.ok(userSet)) } presenter.attach(view) advanceUntilIdle() verifyOrder { @@ -72,7 +80,7 @@ class UsersListPresenterTest { view.show( withArg { assertTrue { it is Users } - assertTrue { (it as Users).users == userList } + assertTrue { (it as Users).users == userSet } }, ) } @@ -81,8 +89,8 @@ class UsersListPresenterTest { @Test fun `when users list contains very long usernames, then truncate long usernames only`() = runTest(dispatcher) { - val userList = listOf("Allie", "Kai", "A very very long username") - coEvery { listUsers() } coAnswersDelayed { flowOf(userList) } + val userSet = setOf("Allie", "Kai", "A very very long username") + coEvery { readPresence.observe() } coAnswersDelayed { flowOf(PresenceResult.ok(userSet)) } presenter.attach(view) advanceUntilIdle() verifyOrder { @@ -99,4 +107,4 @@ class UsersListPresenterTest { companion object { private val dispatcher = StandardTestDispatcher() } -} + } From 22803e269b9940930500a90a8396cce1f7a9ecb4 Mon Sep 17 00:00:00 2001 From: spencerduberry Date: Thu, 11 Dec 2025 17:21:20 +0000 Subject: [PATCH 2/3] #160: fix usersListTest --- .../chatroom/users/UsersListPresenterTest.kt | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt b/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt index 0e5f9c1..8ee6259 100644 --- a/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt +++ b/src/test/kotlin/chatroom/users/UsersListPresenterTest.kt @@ -1,23 +1,23 @@ - package chatroom.users +package chatroom.users - import arch.RokyDispatchers - import chatroom.users.UsersViewState.Empty - import chatroom.users.UsersViewState.Users - import chatserver.* - import coAnswersDelayed - import io.mockk.* - import kotlinx.coroutines.CoroutineScope - import kotlinx.coroutines.ExperimentalCoroutinesApi - import kotlinx.coroutines.flow.flowOf - import kotlinx.coroutines.test.StandardTestDispatcher - import kotlinx.coroutines.test.advanceUntilIdle - import kotlinx.coroutines.test.runTest - import org.junit.jupiter.api.Assertions.* - import org.junit.jupiter.api.BeforeEach - import org.junit.jupiter.api.Test +import arch.RokyDispatchers +import chatroom.users.UsersViewState.Empty +import chatroom.users.UsersViewState.Users +import chatserver.* +import coAnswersDelayed +import io.mockk.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test - @OptIn(ExperimentalCoroutinesApi::class) - class UsersListPresenterTest { +@OptIn(ExperimentalCoroutinesApi::class) +class UsersListPresenterTest { private lateinit var scope: CoroutineScope private lateinit var subscribePresence: SubscribeChatRepository private lateinit var readPresence: ReadChatRepository @@ -34,7 +34,7 @@ every { subscribePresence.subscribe() } just runs every { subscribePresence.unsubscribe() } just runs every { repositories.readPresence() } returns readPresence - coEvery { readPresence.observe() } coAnswersDelayed {flowOf()} + coEvery { readPresence.observe() } coAnswersDelayed { flowOf() } val dispatchers: RokyDispatchers = mockk().apply { every { main } returns dispatcher @@ -80,7 +80,7 @@ view.show( withArg { assertTrue { it is Users } - assertTrue { (it as Users).users == userSet } + assertTrue { (it as Users).users.toSet() == userSet } }, ) } @@ -107,4 +107,4 @@ companion object { private val dispatcher = StandardTestDispatcher() } - } +} From 9a9ae79c8c02ccc5eaf29d1267d416e62c1f2548 Mon Sep 17 00:00:00 2001 From: spencerduberry Date: Thu, 11 Dec 2025 17:26:58 +0000 Subject: [PATCH 3/3] #160: fix remove users list use case --- src/main/kotlin/chatroom/users/Users.kt | 1 - .../kotlin/chatroom/users/UsersListUseCase.kt | 24 --------- .../chatroom/users/UsersListUseCaseTest.kt | 54 ------------------- 3 files changed, 79 deletions(-) delete mode 100644 src/main/kotlin/chatroom/users/UsersListUseCase.kt delete mode 100644 src/test/kotlin/chatroom/users/UsersListUseCaseTest.kt diff --git a/src/main/kotlin/chatroom/users/Users.kt b/src/main/kotlin/chatroom/users/Users.kt index 28ec278..8897018 100644 --- a/src/main/kotlin/chatroom/users/Users.kt +++ b/src/main/kotlin/chatroom/users/Users.kt @@ -8,7 +8,6 @@ val usersModule = module { scope { scoped { UsersPanel(presenter = get()) } - scoped { UsersListUseCase() } scoped { UsersListPresenter( truncate = get(), diff --git a/src/main/kotlin/chatroom/users/UsersListUseCase.kt b/src/main/kotlin/chatroom/users/UsersListUseCase.kt deleted file mode 100644 index ea9b00e..0000000 --- a/src/main/kotlin/chatroom/users/UsersListUseCase.kt +++ /dev/null @@ -1,24 +0,0 @@ -package chatroom.users - -import chatserver.messages.LocalChatMessages -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlin.random.Random -import kotlin.time.Duration.Companion.seconds - -class UsersListUseCase( - private val users: List = LocalChatMessages.sampleUsers, - private val rndInt: () -> Int = { Random.nextInt(4, users.size - 1) }, - private val rndUser: (List) -> String = { it.random() }, -) { - operator fun invoke(): Flow> = - flow { - while (true) { - val size = rndInt() - val list = (2..size).map { rndUser(users) }.distinct().sorted() - emit(list) - delay(10.seconds) - } - } -} diff --git a/src/test/kotlin/chatroom/users/UsersListUseCaseTest.kt b/src/test/kotlin/chatroom/users/UsersListUseCaseTest.kt deleted file mode 100644 index 77835f7..0000000 --- a/src/test/kotlin/chatroom/users/UsersListUseCaseTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package chatroom.users - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.seconds - -@OptIn(ExperimentalCoroutinesApi::class) -class UsersListUseCaseTest { - @Test - fun `given list of users, when selecting users five times, then list size is five`() = - runTest { - var index = 0 - val users = listOf("Terry", "Matthew", "Rob", "Tom", "Kai", "Stefano") - val useCase = - UsersListUseCase( - users = users, - rndInt = { 6 }, - rndUser = { it[index++ % it.size] }, - ) - val emissions = mutableListOf>() - val job = - backgroundScope.launch { - useCase().collect(emissions::add) - } - advanceTimeBy(15.seconds) - assertEquals(5, emissions.first().size) - job.cancel() - } - - @Test - fun `given list of users, when selecting users five times, then emit sorted list of five users`() = - runTest { - var index = 0 - val users = listOf("Terry", "Matthew", "Rob", "Tom", "Kai", "Stefano") - val useCase = - UsersListUseCase( - users = users, - rndInt = { 6 }, - rndUser = { it[index++ % it.size] }, - ) - val emissions = mutableListOf>() - val job = - backgroundScope.launch { - useCase().collect(emissions::add) - } - advanceTimeBy(15.seconds) - assertEquals(listOf("Kai", "Matthew", "Rob", "Terry", "Tom"), emissions.first()) - job.cancel() - } -}