diff --git a/domains/state/src/main/kotlin/app/dapk/state/State.kt b/domains/state/src/main/kotlin/app/dapk/state/State.kt index fc49041..268ea0c 100644 --- a/domains/state/src/main/kotlin/app/dapk/state/State.kt +++ b/domains/state/src/main/kotlin/app/dapk/state/State.kt @@ -76,7 +76,7 @@ fun createReducer( return Reducer { action -> val result = reducersMap.keys .filter { it.java.isAssignableFrom(action::class.java) } - .fold(scope.getState() ?: initialState) { acc, key -> + .fold(scope.getState()) { acc, key -> val actionHandlers = reducersMap[key]!! actionHandlers.fold(acc) { acc, handler -> when (handler) { diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt index d4aaf19..e464af1 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt @@ -6,6 +6,7 @@ import app.dapk.st.core.StateViewModel import app.dapk.st.core.createStateViewModel import app.dapk.st.directory.state.DirectoryEvent import app.dapk.st.directory.state.DirectoryScreenState +import app.dapk.st.directory.state.JobBag import app.dapk.st.directory.state.directoryReducer import app.dapk.st.engine.ChatEngine @@ -15,6 +16,6 @@ class DirectoryModule( ) : ProvidableModule { internal fun directoryViewModel(): StateViewModel { - return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), it) } + return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), it) } } } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt index 9325a0b..a4dfe69 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt @@ -3,32 +3,33 @@ package app.dapk.st.directory.state import app.dapk.st.directory.ShortcutHandler import app.dapk.st.engine.ChatEngine import app.dapk.state.* -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +private const val KEY_SYNCING_JOB = "sync" + internal fun directoryReducer( chatEngine: ChatEngine, shortcutHandler: ShortcutHandler, + jobBag: JobBag, eventEmitter: suspend (DirectoryEvent) -> Unit, ): ReducerFactory { - var syncJob: Job? = null return createReducer( initialState = DirectoryScreenState.EmptyLoading, multi(ComponentLifecycle::class) { action -> when (action) { ComponentLifecycle.OnVisible -> async { _ -> - syncJob = chatEngine.directory().onEach { + jobBag.add(KEY_SYNCING_JOB, chatEngine.directory().onEach { shortcutHandler.onDirectoryUpdate(it.map { it.overview }) when (it.isEmpty()) { true -> dispatch(DirectoryStateChange.Empty) false -> dispatch(DirectoryStateChange.Content(it)) } - }.launchIn(coroutineScope) + }.launchIn(coroutineScope)) } - ComponentLifecycle.OnGone -> sideEffect { _, _ -> syncJob?.cancel() } + ComponentLifecycle.OnGone -> sideEffect { _, _ -> jobBag.cancel(KEY_SYNCING_JOB) } } }, diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/state/JobBag.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/state/JobBag.kt new file mode 100644 index 0000000..b9d3b07 --- /dev/null +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/state/JobBag.kt @@ -0,0 +1,17 @@ +package app.dapk.st.directory.state + +import kotlinx.coroutines.Job + +class JobBag { + + private val jobs = mutableMapOf() + + fun add(key: String, job: Job) { + jobs[key] = job + } + + fun cancel(key: String) { + jobs.remove(key)?.cancel() + } + +} \ No newline at end of file diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt new file mode 100644 index 0000000..5e77fd1 --- /dev/null +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt @@ -0,0 +1,85 @@ +package app.dapk.st.directory + +import app.dapk.st.directory.state.* +import app.dapk.st.engine.DirectoryItem +import app.dapk.st.engine.UnreadCount +import fake.FakeChatEngine +import fixture.aRoomOverview +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.junit.Test +import test.expect + +private val AN_OVERVIEW = aRoomOverview() +private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null) + +class DirectoryReducerTest { + + private val fakeShortcutHandler = FakeShortcutHandler() + private val fakeChatEngine = FakeChatEngine() + private val fakeJobBag = FakeJobBag() + private val fakeEventSource = FakeEventSource() + + private val reducer = directoryReducer( + fakeChatEngine, + fakeShortcutHandler.instance, + fakeJobBag.instance, + fakeEventSource, + ) + + @Test + fun `initial state is empty loading`() = runReducerTest(reducer) { + assertInitialState(DirectoryScreenState.EmptyLoading) + } + + @Test + fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest(reducer) { + fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) } + fakeJobBag.instance.expect { it.add("sync", any()) } + fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE))) + + reduce(ComponentLifecycle.OnVisible) + + assertNoStateChange() + assertDispatches(listOf(DirectoryStateChange.Content(listOf(AN_OVERVIEW_STATE)))) + } + + @Test + fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest(reducer) { + fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) } + fakeJobBag.instance.expect { it.add("sync", any()) } + fakeChatEngine.givenDirectory().returns(flowOf(emptyList())) + + reduce(ComponentLifecycle.OnVisible) + + assertNoStateChange() + assertDispatches(listOf(DirectoryStateChange.Empty)) + } + + @Test + fun `when Gone, then cancels sync job`() = runReducerTest(reducer) { + fakeJobBag.instance.expect { it.cancel("sync") } + + reduce(ComponentLifecycle.OnGone) + + assertNoStateChange() + assertNoDispatches() + } + + @Test + fun `given ScrollToTop, then emits Scroll event`() = runReducerTest(reducer) { + reduce(DirectorySideEffect.ScrollToTop) + + assertNoStateChange() + assertNoDispatches() + fakeEventSource.assertEvents(listOf(DirectoryEvent.ScrollToTop)) + } +} + +internal class FakeShortcutHandler { + val instance = mockk() +} + +class FakeJobBag { + val instance = mockk() +} diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt deleted file mode 100644 index bc71e31..0000000 --- a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryViewModelTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package app.dapk.st.directory - -import ViewModelTest -import app.dapk.st.directory.state.DirectoryScreenState -import app.dapk.st.directory.state.DirectoryState -import app.dapk.st.engine.DirectoryItem -import app.dapk.st.engine.UnreadCount -import fake.FakeChatEngine -import fixture.aRoomOverview -import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf -import org.junit.Test - -private val AN_OVERVIEW = aRoomOverview() -private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null) - -class DirectoryViewModelTest { - - private val runViewModelTest = ViewModelTest() - private val fakeShortcutHandler = FakeShortcutHandler() - private val fakeChatEngine = FakeChatEngine() - - private val viewModel = DirectoryState( - fakeShortcutHandler.instance, - fakeChatEngine, - runViewModelTest.testMutableStateFactory(), - ) - - @Test - fun `when creating view model, then initial state is empty loading`() = runViewModelTest { - viewModel.test() - - assertInitialState(DirectoryScreenState.EmptyLoading) - } - - @Test - fun `when starting, then updates shortcuts and emits room state`() = runViewModelTest { - fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) } - fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE))) - - viewModel.test().start() - - assertStates(DirectoryScreenState.Content(listOf(AN_OVERVIEW_STATE))) - verifyExpects() - } -} - -class FakeShortcutHandler { - val instance = mockk() -} \ No newline at end of file diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/FakeEventSource.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/FakeEventSource.kt new file mode 100644 index 0000000..5caa5f2 --- /dev/null +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/FakeEventSource.kt @@ -0,0 +1,16 @@ +package app.dapk.st.directory + +import org.amshove.kluent.internal.assertEquals + +class FakeEventSource : (E) -> Unit { + + val captures = mutableListOf() + + override fun invoke(event: E) { + captures.add(event) + } + + fun assertEvents(expected: List) { + assertEquals(expected, captures) + } +} \ No newline at end of file diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/ReducerTestScope.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/ReducerTestScope.kt new file mode 100644 index 0000000..bdc9c5e --- /dev/null +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/ReducerTestScope.kt @@ -0,0 +1,65 @@ +package app.dapk.st.directory + +import app.dapk.state.Action +import app.dapk.state.Reducer +import app.dapk.state.ReducerFactory +import app.dapk.state.ReducerScope +import kotlinx.coroutines.CoroutineScope +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 + +fun runReducerTest(reducerFactory: ReducerFactory, block: suspend ReducerTestScope.() -> Unit) { + runTest { + val expectTestScope = ExpectTest(coroutineContext) + block(ReducerTestScope(reducerFactory, expectTestScope)) + expectTestScope.verifyExpects() + } +} + +class ReducerTestScope( + private val reducerFactory: ReducerFactory, + private val expectTestScope: ExpectTestScope +) : ExpectTestScope by expectTestScope, Reducer { + + private var manualState: S? = null + private var capturedResult: S? = null + + private val actionCaptures = mutableListOf() + private val reducerScope = object : ReducerScope { + override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher()) + override suspend fun dispatch(action: Action) { + actionCaptures.add(action) + } + + override fun getState() = manualState ?: reducerFactory.initialState() + } + private val reducer: Reducer = reducerFactory.create(reducerScope) + + override suspend fun reduce(action: Action) = reducer.reduce(action).also { + capturedResult = it + } + + fun setState(state: S) { + manualState = state + } + + fun assertInitialState(expected: S) { + reducerFactory.initialState() shouldBeEqualTo expected + } + + fun assertDispatches(expected: List) { + assertEquals(expected, actionCaptures) + } + + fun assertNoDispatches() { + assertEquals(emptyList(), actionCaptures) + } + + fun assertNoStateChange() { + assertEquals(reducerFactory.initialState(), capturedResult) + } +} \ No newline at end of file