update directory viewmodel test to reducer style

This commit is contained in:
Adam Brown 2022-10-31 22:32:58 +00:00
parent 9acf5ce479
commit 38e242e8d1
8 changed files with 192 additions and 57 deletions

View File

@ -76,7 +76,7 @@ fun <S> createReducer(
return Reducer { action -> return Reducer { action ->
val result = reducersMap.keys val result = reducersMap.keys
.filter { it.java.isAssignableFrom(action::class.java) } .filter { it.java.isAssignableFrom(action::class.java) }
.fold(scope.getState() ?: initialState) { acc, key -> .fold(scope.getState()) { acc, key ->
val actionHandlers = reducersMap[key]!! val actionHandlers = reducersMap[key]!!
actionHandlers.fold(acc) { acc, handler -> actionHandlers.fold(acc) { acc, handler ->
when (handler) { when (handler) {

View File

@ -6,6 +6,7 @@ import app.dapk.st.core.StateViewModel
import app.dapk.st.core.createStateViewModel import app.dapk.st.core.createStateViewModel
import app.dapk.st.directory.state.DirectoryEvent import app.dapk.st.directory.state.DirectoryEvent
import app.dapk.st.directory.state.DirectoryScreenState 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.directory.state.directoryReducer
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
@ -15,6 +16,6 @@ class DirectoryModule(
) : ProvidableModule { ) : ProvidableModule {
internal fun directoryViewModel(): StateViewModel<DirectoryScreenState, DirectoryEvent> { internal fun directoryViewModel(): StateViewModel<DirectoryScreenState, DirectoryEvent> {
return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), it) } return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), it) }
} }
} }

View File

@ -3,32 +3,33 @@ package app.dapk.st.directory.state
import app.dapk.st.directory.ShortcutHandler import app.dapk.st.directory.ShortcutHandler
import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.ChatEngine
import app.dapk.state.* import app.dapk.state.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
private const val KEY_SYNCING_JOB = "sync"
internal fun directoryReducer( internal fun directoryReducer(
chatEngine: ChatEngine, chatEngine: ChatEngine,
shortcutHandler: ShortcutHandler, shortcutHandler: ShortcutHandler,
jobBag: JobBag,
eventEmitter: suspend (DirectoryEvent) -> Unit, eventEmitter: suspend (DirectoryEvent) -> Unit,
): ReducerFactory<DirectoryScreenState> { ): ReducerFactory<DirectoryScreenState> {
var syncJob: Job? = null
return createReducer( return createReducer(
initialState = DirectoryScreenState.EmptyLoading, initialState = DirectoryScreenState.EmptyLoading,
multi(ComponentLifecycle::class) { action -> multi(ComponentLifecycle::class) { action ->
when (action) { when (action) {
ComponentLifecycle.OnVisible -> async { _ -> ComponentLifecycle.OnVisible -> async { _ ->
syncJob = chatEngine.directory().onEach { jobBag.add(KEY_SYNCING_JOB, chatEngine.directory().onEach {
shortcutHandler.onDirectoryUpdate(it.map { it.overview }) shortcutHandler.onDirectoryUpdate(it.map { it.overview })
when (it.isEmpty()) { when (it.isEmpty()) {
true -> dispatch(DirectoryStateChange.Empty) true -> dispatch(DirectoryStateChange.Empty)
false -> dispatch(DirectoryStateChange.Content(it)) false -> dispatch(DirectoryStateChange.Content(it))
} }
}.launchIn(coroutineScope) }.launchIn(coroutineScope))
} }
ComponentLifecycle.OnGone -> sideEffect { _, _ -> syncJob?.cancel() } ComponentLifecycle.OnGone -> sideEffect { _, _ -> jobBag.cancel(KEY_SYNCING_JOB) }
} }
}, },

View File

@ -0,0 +1,17 @@
package app.dapk.st.directory.state
import kotlinx.coroutines.Job
class JobBag {
private val jobs = mutableMapOf<String, Job>()
fun add(key: String, job: Job) {
jobs[key] = job
}
fun cancel(key: String) {
jobs.remove(key)?.cancel()
}
}

View File

@ -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<DirectoryEvent>()
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<ShortcutHandler>()
}
class FakeJobBag {
val instance = mockk<JobBag>()
}

View File

@ -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<ShortcutHandler>()
}

View File

@ -0,0 +1,16 @@
package app.dapk.st.directory
import org.amshove.kluent.internal.assertEquals
class FakeEventSource<E> : (E) -> Unit {
val captures = mutableListOf<E>()
override fun invoke(event: E) {
captures.add(event)
}
fun assertEvents(expected: List<E>) {
assertEquals(expected, captures)
}
}

View File

@ -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 <S> runReducerTest(reducerFactory: ReducerFactory<S>, block: suspend ReducerTestScope<S>.() -> Unit) {
runTest {
val expectTestScope = ExpectTest(coroutineContext)
block(ReducerTestScope(reducerFactory, expectTestScope))
expectTestScope.verifyExpects()
}
}
class ReducerTestScope<S>(
private val reducerFactory: ReducerFactory<S>,
private val expectTestScope: ExpectTestScope
) : ExpectTestScope by expectTestScope, Reducer<S> {
private var manualState: S? = null
private var capturedResult: S? = null
private val actionCaptures = mutableListOf<Action>()
private val reducerScope = object : ReducerScope<S> {
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
override suspend fun dispatch(action: Action) {
actionCaptures.add(action)
}
override fun getState() = manualState ?: reducerFactory.initialState()
}
private val reducer: Reducer<S> = 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 <S> assertDispatches(expected: List<S>) {
assertEquals(expected, actionCaptures)
}
fun assertNoDispatches() {
assertEquals(emptyList(), actionCaptures)
}
fun assertNoStateChange() {
assertEquals(reducerFactory.initialState(), capturedResult)
}
}