diff --git a/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt index e6b152ce..be8abafb 100644 --- a/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt +++ b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt @@ -6,10 +6,10 @@ import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.PublishSubject class BaseStore( - private val schedulingStrategy: SchedulingStrategy, - private val reducer: Reducer, - private val middlewares: List>, - private val initialValue: S + private val schedulingStrategy: SchedulingStrategy, + private val reducer: Reducer, + private val middlewares: List>, + private val initialValue: S ) : Store { private val changes = PublishSubject.create() private val state = BehaviorSubject.createDefault(initialValue) @@ -19,38 +19,38 @@ class BaseStore( val disposables = CompositeDisposable() val newState = changes.scan( - initialValue, { state, change -> - reducer.reduce(state, change) - } + initialValue, { state, change -> + reducer.reduce(state, change) + } ) disposables.add( - newState - .subscribeOn(schedulingStrategy.work) - .subscribe(state::onNext) + newState + .subscribeOn(schedulingStrategy.work) + .subscribe(state::onNext) ) for (middleware in middlewares) { val observable = middleware - .bind(actions, state) - .subscribeOn(schedulingStrategy.work) + .bind(actions, state) + .subscribeOn(schedulingStrategy.work) disposables.add(observable.subscribe(changes::onNext)) } return disposables } - override fun bind(view: MVIView): Disposable { + override fun bind(displayer: Displayer): Disposable { val disposables = CompositeDisposable() - disposables.add(view.actions.subscribe(actions::onNext)) + disposables.add(displayer.actions.subscribe(actions::onNext)) disposables.add( - state - .observeOn(schedulingStrategy.ui) - .subscribe(view::render) + state + .observeOn(schedulingStrategy.ui) + .subscribe(displayer::render) ) return disposables } -} +} \ No newline at end of file diff --git a/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt index effa2cda..506adb17 100644 --- a/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt +++ b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt @@ -3,11 +3,8 @@ package com.novoda.movies.mvi.search import io.reactivex.Observable import io.reactivex.disposables.Disposable -// TODO: Consider a better interface for the view so that we can compose -// more views in the same Activity -interface MVIView { +interface Displayer { val actions: Observable - fun render(state: S) } @@ -21,5 +18,5 @@ interface Middleware { interface Store { fun wire(): Disposable - fun bind(view: MVIView): Disposable + fun bind(displayer: Displayer): Disposable } diff --git a/ModelViewIntentSample/search/build.gradle b/ModelViewIntentSample/search/build.gradle index 1f3a1e15..5824a41a 100644 --- a/ModelViewIntentSample/search/build.gradle +++ b/ModelViewIntentSample/search/build.gradle @@ -48,4 +48,5 @@ dependencies { testImplementation libraries.test.mockitoKotlin testImplementation libraries.test.mockitoInline testImplementation libraries.test.assertj + implementation 'android.arch.lifecycle:extensions:1.1.1' } diff --git a/ModelViewIntentSample/search/src/main/AndroidManifest.xml b/ModelViewIntentSample/search/src/main/AndroidManifest.xml index 6ad7d813..389d4cfa 100644 --- a/ModelViewIntentSample/search/src/main/AndroidManifest.xml +++ b/ModelViewIntentSample/search/src/main/AndroidManifest.xml @@ -9,8 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> - + diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt index cedb8884..815f658d 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt @@ -13,7 +13,6 @@ internal class ApiSearchResultsConverter( return apiSearchResults.toSearchResults() } - private fun ApiSearchResults.toSearchResults(): SearchResults { return SearchResults( items = results.map { diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt index 0c1a3294..8f9f6456 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt @@ -4,13 +4,13 @@ import com.novoda.movies.mvi.search.domain.SearchResults import io.reactivex.Single internal class SearchBackend( - private val searchApi: SearchApi, - private val searchConverter: ApiSearchResultsConverter + private val searchApi: SearchApi, + private val searchConverter: ApiSearchResultsConverter ) { fun search(query: String): Single { return searchApi - .search(query) - .map(searchConverter::convert) + .search(query) + .map(searchConverter::convert) } } diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt index 559b9989..516ca809 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt @@ -8,6 +8,8 @@ import com.novoda.movies.mvi.search.data.ApiSearchResultsConverter import com.novoda.movies.mvi.search.data.SearchApi import com.novoda.movies.mvi.search.data.SearchBackend import com.novoda.movies.mvi.search.presentation.SearchResultsConverter +import com.novoda.movies.mvi.search.presentation.SearchStore +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State import com.novoda.movies.mvi.search.presentation.ViewSearchResults internal class SearchDependencyProvider( @@ -23,12 +25,12 @@ internal class SearchDependencyProvider( ) } - fun provideSearchStore(): BaseStore { + fun provideSearchStore(): SearchStore { return BaseStore( - reducer = SearchReducer(provideSearchResultsConverter()), - schedulingStrategy = ProductionSchedulingStrategy(), - middlewares = listOf(SearchMiddleware(provideSearchBackend(), ProductionSchedulingStrategy().work)), - initialValue = ScreenState(queryString = "", results = ViewSearchResults.emptyResults) + reducer = SearchReducer(provideSearchResultsConverter()), + schedulingStrategy = ProductionSchedulingStrategy(), + middlewares = listOf(SearchMiddleware(provideSearchBackend(), ProductionSchedulingStrategy().work)), + initialValue = State(queryString = "", results = ViewSearchResults.emptyResults) ) } diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt index ede654a5..a863fd54 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt @@ -2,43 +2,46 @@ package com.novoda.movies.mvi.search.domain import com.novoda.movies.mvi.search.Middleware import com.novoda.movies.mvi.search.data.SearchBackend -import com.novoda.movies.mvi.search.domain.ScreenStateChanges.* +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes.* +import com.novoda.movies.mvi.search.presentation.SearchViewModel +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action.* +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State import io.reactivex.Observable import io.reactivex.Scheduler import io.reactivex.functions.BiFunction - internal class SearchMiddleware( private val backend: SearchBackend, private val workScheduler: Scheduler -) : Middleware { +) : Middleware { - override fun bind(actions: Observable, state: Observable): Observable { + override fun bind(actions: Observable, state: Observable): Observable { return actions .withLatestFrom(state, actionToState()) .switchMap { (action, state) -> handle(action, state) } } - private fun actionToState(): BiFunction> = + private fun actionToState(): BiFunction> = BiFunction { action, state -> action to state } - private fun handle(action: SearchAction, state: ScreenState): Observable = + private fun handle(action: SearchViewModel.Action, state: State): Observable = when (action) { - is SearchAction.ChangeQuery -> Observable.just(UpdateSearchQuery(action.queryString)) - is SearchAction.ExecuteSearch -> processAction(state) - is SearchAction.ClearQuery -> processClearQuery() + is ChangeQuery -> Observable.just(UpdateSearchQuery(action.queryString)) + is ExecuteSearch -> processAction(state) + is ClearQuery -> processClearQuery() } - private fun processClearQuery(): Observable { - val updateQuery = Observable.just(UpdateSearchQuery("") as ScreenStateChanges) + private fun processClearQuery(): Observable { + val updateQuery = Observable.just(UpdateSearchQuery("") as Changes) val removeResults = Observable.just(RemoveResults) return updateQuery.concatWith(removeResults) } - private fun processAction(state: ScreenState): Observable { + private fun processAction(state: State): Observable { val loadContent = backend.search(state.queryString) .toObservable() - .map { searchResult -> AddResults(searchResult) as ScreenStateChanges } + .map { searchResult -> AddResults(searchResult) as Changes } .startWith(ShowProgress) .onErrorReturn { throwable -> HandleError(throwable) } val hideProgress = Observable.just(HideProgress) @@ -49,10 +52,3 @@ internal class SearchMiddleware( } } -internal sealed class SearchAction { - data class ChangeQuery(val queryString: String) : SearchAction() - object ExecuteSearch : SearchAction() - object ClearQuery : SearchAction() -} - - diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt index f014a7a3..3d962170 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt @@ -1,61 +1,56 @@ package com.novoda.movies.mvi.search.domain import com.novoda.movies.mvi.search.Reducer +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes.* import com.novoda.movies.mvi.search.presentation.SearchResultsConverter +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State import com.novoda.movies.mvi.search.presentation.ViewSearchResults internal class SearchReducer( - private val searchResultsConverter: SearchResultsConverter -) : Reducer { - - override fun reduce(state: ScreenState, change: ScreenStateChanges): ScreenState = - when (change) { - is ScreenStateChanges.ShowProgress -> state.showLoading() - is ScreenStateChanges.HideProgress -> state.hideLoading() - is ScreenStateChanges.AddResults -> state.addResults(change.results) - is ScreenStateChanges.RemoveResults -> state.removeResults() - is ScreenStateChanges.UpdateSearchQuery -> state.updateQuery(change.queryString) - is ScreenStateChanges.HandleError -> state.toError(change.throwable) - } - - private fun ScreenState.addResults(results: SearchResults): ScreenState { + private val searchResultsConverter: SearchResultsConverter +) : Reducer { + + override fun reduce(state: State, change: Changes): State = + when (change) { + is ShowProgress -> state.showLoading() + is HideProgress -> state.hideLoading() + is AddResults -> state.addResults(change.results) + is RemoveResults -> state.removeResults() + is UpdateSearchQuery -> state.updateQuery(change.queryString) + is HandleError -> state.toError(change.throwable) + } + + private fun State.addResults(results: SearchResults): State { return copy(results = searchResultsConverter.convert(results)) } + + sealed class Changes { + object ShowProgress : Changes() + object HideProgress : Changes() + data class AddResults(val results: SearchResults) : Changes() + object RemoveResults : Changes() + data class HandleError(val throwable: Throwable) : Changes() + data class UpdateSearchQuery(val queryString: String) : Changes() + } + } -private fun ScreenState.removeResults(): ScreenState { +private fun State.removeResults(): State { return copy(results = ViewSearchResults.emptyResults) } -private fun ScreenState.toError(throwable: Throwable): ScreenState { +private fun State.toError(throwable: Throwable): State { return copy(error = throwable) } -private fun ScreenState.updateQuery(queryString: String): ScreenState { +private fun State.updateQuery(queryString: String): State { return copy(queryString = queryString) } -private fun ScreenState.hideLoading(): ScreenState { +private fun State.hideLoading(): State { return copy(loading = false) } -private fun ScreenState.showLoading(): ScreenState { +private fun State.showLoading(): State { return copy(loading = true) } - -internal data class ScreenState( - var queryString: String, - var loading: Boolean = false, - var results: ViewSearchResults, - var error: Throwable? = null -) - -sealed class ScreenStateChanges { - - object ShowProgress : ScreenStateChanges() - object HideProgress : ScreenStateChanges() - data class AddResults(val results: SearchResults) : ScreenStateChanges() - object RemoveResults: ScreenStateChanges() - data class HandleError(val throwable: Throwable) : ScreenStateChanges() - data class UpdateSearchQuery(val queryString: String) : ScreenStateChanges() -} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt index 3e1ec260..3ccd97a7 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt @@ -1,74 +1,75 @@ package com.novoda.movies.mvi.search.presentation +import android.arch.lifecycle.ViewModel +import android.arch.lifecycle.ViewModelProvider +import android.arch.lifecycle.ViewModelProviders import android.os.Bundle import android.support.v7.app.AppCompatActivity import android.util.Log -import com.novoda.movies.mvi.search.BaseStore +import android.view.View.INVISIBLE +import android.view.View.VISIBLE import com.novoda.movies.mvi.search.Dependencies -import com.novoda.movies.mvi.search.MVIView +import com.novoda.movies.mvi.search.Displayer import com.novoda.movies.mvi.search.R -import com.novoda.movies.mvi.search.domain.ScreenState -import com.novoda.movies.mvi.search.domain.ScreenStateChanges -import com.novoda.movies.mvi.search.domain.SearchAction import com.novoda.movies.mvi.search.domain.SearchDependencyProvider +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State import io.reactivex.Observable -import io.reactivex.disposables.Disposable import kotlinx.android.synthetic.main.activity_search.* -internal class SearchActivity : AppCompatActivity(), MVIView { +internal class SearchActivity : AppCompatActivity(), + Displayer { private lateinit var searchInput: SearchInputView private lateinit var resultsView: SearchResultsView - lateinit var screenStore: BaseStore + private lateinit var viewModel: SearchViewModel - override val actions: Observable + override val actions: Observable get() = searchInput.actions - private var wireDisposable: Disposable? = null - private var bindDisposable: Disposable? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Injector().inject(this) + viewModel = ViewModelProviders.of(this, SearchViewModelFactory(this)) + .get(SearchViewModel::class.java) setContentView(R.layout.activity_search) searchInput = search_input resultsView = search_results - - wireDisposable = screenStore.wire() } override fun onStart() { super.onStart() - bindDisposable = screenStore.bind(this) + viewModel.bind(this) } - override fun render(state: ScreenState) { + override fun render(state: State) { searchInput.currentQuery = state.queryString resultsView.showResults(state.results) + error_view.visibility = if (state.error != null) VISIBLE else INVISIBLE + loading_spinner.visibility = if (state.loading) VISIBLE else INVISIBLE + - Log.v("APP", "state: $state") + Log.v("APP_STATE", "state: $state") } override fun onStop() { - bindDisposable?.dispose() + viewModel.unbind() super.onStop() } +} - override fun onDestroy() { - wireDisposable?.dispose() - super.onDestroy() - } +internal class SearchViewModelFactory( + private val searchActivity: SearchActivity) : ViewModelProvider.Factory { - class Injector { - fun inject(searchActivity: SearchActivity) { - val dependencies = searchActivity.application as Dependencies - val networkDependencyProvider = dependencies.networkDependencyProvider - val searchDependencyProvider = SearchDependencyProvider( + override fun create(modelClass: Class): T { + val dependencies = searchActivity.application as Dependencies + val networkDependencyProvider = dependencies.networkDependencyProvider + val searchDependencyProvider = SearchDependencyProvider( networkDependencyProvider, dependencies.endpoints - ) - searchActivity.screenStore = searchDependencyProvider.provideSearchStore() - } + ) + + return SearchViewModel(searchDependencyProvider.provideSearchStore()) + .let { modelClass.cast(it) }!! } } diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt index 3b85ece5..1899033b 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt @@ -15,7 +15,8 @@ import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.TextView import com.novoda.movies.mvi.search.R -import com.novoda.movies.mvi.search.domain.SearchAction +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action.* import io.reactivex.Observable import io.reactivex.subjects.PublishSubject import kotlinx.android.synthetic.main.search_bar.view.* @@ -29,7 +30,7 @@ internal class SearchInputView @JvmOverloads constructor( private lateinit var searchInput: EditText private lateinit var clearTextButton: View - private val actionStream: PublishSubject = PublishSubject.create() + private val actionStream: PublishSubject = PublishSubject.create() var currentQuery: String get() = searchInput.text.toString() @@ -38,7 +39,7 @@ internal class SearchInputView @JvmOverloads constructor( searchInput.setSelection(text.length) } - val actions: Observable + val actions: Observable get() = actionStream private fun showKeyboard() { @@ -60,7 +61,7 @@ internal class SearchInputView @JvmOverloads constructor( searchInput.isSaveEnabled = false searchInput.setOnEditorActionListener { inputView, actionId, keyEvent -> if (actionId == EditorInfo.IME_ACTION_SEARCH || enterKeyPressed(keyEvent)) { - actionStream.onNext(SearchAction.ExecuteSearch) + actionStream.onNext(ExecuteSearch) inputView.hideKeyboard() inputView.clearFocus() return@setOnEditorActionListener true @@ -72,7 +73,7 @@ internal class SearchInputView @JvmOverloads constructor( } private fun clearText() { - actionStream.onNext(SearchAction.ClearQuery) + actionStream.onNext(ClearQuery) } private fun enterKeyPressed(keyEvent: KeyEvent?): Boolean { @@ -84,7 +85,7 @@ internal class SearchInputView @JvmOverloads constructor( private val textChangedListener = object : AfterTextChangedWatcher { override fun afterTextChanged(text: Editable) { - actionStream.onNext(SearchAction.ChangeQuery(text.toString())) + actionStream.onNext(ChangeQuery(text.toString())) val showClear = text.isNotEmpty() clearTextButton.visibility = if (showClear) View.VISIBLE else View.GONE diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchViewModel.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchViewModel.kt new file mode 100644 index 00000000..0e7f8b96 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchViewModel.kt @@ -0,0 +1,41 @@ +package com.novoda.movies.mvi.search.presentation + +import android.arch.lifecycle.ViewModel +import com.novoda.movies.mvi.search.BaseStore +import com.novoda.movies.mvi.search.Displayer +import com.novoda.movies.mvi.search.domain.SearchReducer +import io.reactivex.disposables.Disposable + +internal typealias SearchStore = BaseStore +internal typealias SearchDisplayer = Displayer + +internal class SearchViewModel(private val store: SearchStore) : ViewModel() { + private val wireDisposable = store.wire() + private var bindDisposable: Disposable? = null + + fun bind(displayer: SearchDisplayer) { + bindDisposable = store.bind(displayer = displayer) + } + + override fun onCleared() { + super.onCleared() + + unbind() + wireDisposable.dispose() + } + + fun unbind() = bindDisposable?.dispose() + + internal data class State( + var queryString: String, + var loading: Boolean = false, + var results: ViewSearchResults, + var error: Throwable? = null + ) + + internal sealed class Action { + data class ChangeQuery(val queryString: String) : Action() + object ExecuteSearch : Action() + object ClearQuery : Action() + } +} diff --git a/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml b/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml index 8a71243f..a125c63a 100644 --- a/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml +++ b/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml @@ -21,9 +21,9 @@ android:layout_height="44dp" android:focusable="true" android:focusableInTouchMode="true"/> - + + + + diff --git a/ModelViewIntentSample/search/src/main/res/values/strings.xml b/ModelViewIntentSample/search/src/main/res/values/strings.xml index 89b5d2bc..ed1d9fff 100644 --- a/ModelViewIntentSample/search/src/main/res/values/strings.xml +++ b/ModelViewIntentSample/search/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Search No Results found for \"%s\" + There was an error fetching the movies diff --git a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt index bc878538..c846e39e 100644 --- a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt +++ b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt @@ -4,6 +4,9 @@ import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.stub import com.novoda.movies.mvi.search.data.SearchBackend +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State import com.novoda.movies.mvi.search.presentation.ViewSearchResults import io.reactivex.Single import io.reactivex.observers.TestObserver @@ -17,9 +20,9 @@ class SearchMiddlewareTest { private val backend: SearchBackend = mock() private val searchMiddleware = SearchMiddleware(backend, Schedulers.trampoline()) - private val actions = PublishSubject.create() - private val state = PublishSubject.create() - private lateinit var changes: TestObserver + private val actions = PublishSubject.create() + private val state = PublishSubject.create() + private lateinit var changes: TestObserver @Before fun setUp() { @@ -28,52 +31,52 @@ class SearchMiddlewareTest { @Test fun `GIVEN state with query WHEN query changed THEN query is updated`() { - state.onNext(ScreenState(queryString = "iron man", results = ViewSearchResults.emptyResults)) + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) - actions.onNext(SearchAction.ChangeQuery(queryString = "superman")) + actions.onNext(Action.ChangeQuery(queryString = "superman")) - changes.assertValue(ScreenStateChanges.UpdateSearchQuery("superman")) + changes.assertValue(Changes.UpdateSearchQuery("superman")) } @Test fun `WHEN query cleared THEN updated query is empty AND results are removed`() { - state.onNext(ScreenState(queryString = "iron man", results = ViewSearchResults.emptyResults)) + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) - actions.onNext(SearchAction.ClearQuery) + actions.onNext(Action.ClearQuery) changes.assertValues( - ScreenStateChanges.UpdateSearchQuery(""), - ScreenStateChanges.RemoveResults + Changes.UpdateSearchQuery(""), + Changes.RemoveResults ) } @Test - fun `GIVEN backend has results WHEN execute search THEN first show progress AND add results AND hide progress`() { + fun `GIVEN dataSource has results WHEN execute search THEN search is in progress AND search is completed`() { val searchResults = SearchResults(items = listOf()) - state.onNext(ScreenState(queryString = "iron man", results = ViewSearchResults.emptyResults)) + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) backend.stub { on { search("iron man") } doReturn Single.just(searchResults) } - actions.onNext(SearchAction.ExecuteSearch) + actions.onNext(Action.ExecuteSearch) changes.assertValues( - ScreenStateChanges.ShowProgress, - ScreenStateChanges.AddResults(results = searchResults), - ScreenStateChanges.HideProgress + Changes.ShowProgress, + Changes.AddResults(results = searchResults), + Changes.HideProgress ) } @Test - fun `GIVEN backend errors WHEN execute search THEN search is in progress AND search failed`() { - val exception = IllegalStateException("backend is down") - state.onNext(ScreenState(queryString = "iron man", results = ViewSearchResults.emptyResults)) + fun `GIVEN dataSource errors WHEN execute search THEN search is in progress AND search failed`() { + val exception = Throwable() + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) backend.stub { on { search("iron man") } doReturn (Single.error(exception)) } - actions.onNext(SearchAction.ExecuteSearch) + actions.onNext(Action.ExecuteSearch) changes.assertValues( - ScreenStateChanges.ShowProgress, - ScreenStateChanges.HandleError(exception), - ScreenStateChanges.HideProgress + Changes.ShowProgress, + Changes.HandleError(exception), + Changes.HideProgress ) }