update directory viewmodel test to reducer style
This commit is contained in:
parent
9acf5ce479
commit
38e242e8d1
|
@ -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) {
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>()
|
||||||
|
}
|
|
@ -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>()
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue