diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt index 8871676..2f6402c 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt @@ -12,6 +12,6 @@ sealed class QuerySearchSuggestion( data class ResultSuggestion(val volume: String) : QuerySearchSuggestion(volume) @Parcelize - data class ErrorSuggestion(val volume: String) : QuerySearchSuggestion(volume) + data class ErrorSuggestion(val error: String) : QuerySearchSuggestion(error) } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt index 43c7caf..eebcf41 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt @@ -26,8 +26,8 @@ interface ClickConsumer : Consumer interface SearchConsumer : Consumer @BindingAdapter("on_suggestion_click", "on_search", requireAll = false) -fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer?, searchConsumer: SearchConsumer?) { - search.setOnSearchListener(object : FloatingSearchView.OnSearchListener { +fun FloatingSearchView.bindSuggestionClick(clickConsumer: ClickConsumer?, searchConsumer: SearchConsumer?) { + setOnSearchListener(object : FloatingSearchView.OnSearchListener { override fun onSearchAction(currentQuery: String) { searchConsumer?.apply { searchConsumer.accept(currentQuery) } } @@ -37,7 +37,7 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer when (searchSuggestion) { is ResultSuggestion -> { clickConsumer.accept(searchSuggestion) - search.setSearchFocused(false) + setSearchFocused(false) } } } @@ -49,32 +49,32 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer * Limit scope to apply using RecyclerView as BindingAdapter */ @BindingAdapter("adapter", "state_change", "on_selected", requireAll = false) -fun bindStateData(recycler: RecyclerView, inputAdapter: QueryVolumeAdapter, data: QueryViewState?, consumer: OnVolumeSelectedListener) = - with(recycler) { - if (adapter == null) { - inputAdapter.onVolumeSelectedListener = consumer - adapter = inputAdapter - } +fun RecyclerView.bindStateData(inputAdapter: QueryVolumeAdapter, data: QueryViewState?, consumer: OnVolumeSelectedListener) { + if (adapter == null) { + inputAdapter.onVolumeSelectedListener = consumer + adapter = inputAdapter + } + + data?.let { + bindError(data.error) + bindResults(data.results) + } +} - data?.let { - bindError(data.error) - bindResults(data.results) - } - } @BindingAdapter("state_change") -fun bindStateVisibility(errorContainer: FrameLayout, data: QueryViewState?) = data?.let { state -> - state.error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it } +fun FrameLayout.bindStateVisibility(data: QueryViewState?) = data?.let { state -> + state.error.fold({ View.GONE }, { View.VISIBLE }).let { visibility = it } } @BindingAdapter("state_change") -fun bindErrorText(errorText: TextView, data: QueryViewState?) = data?.let { state -> - state.error.fold({ Unit }, { errorText.text = it.toHumanResponse() }) +fun TextView.bindErrorText(data: QueryViewState?) = data?.let { state -> + state.error.fold({ Unit }, { text = it.toHumanResponse() }) } @BindingAdapter("state_change") -fun bindProgress(progress: ProgressBar, data: QueryViewState?) = data?.let { state -> - progress.gone(!state.loading) +fun ProgressBar.bindProgress(data: QueryViewState?) = data?.let { state -> + gone(!state.loading) } private fun RecyclerView.bindError(error: Option): Unit = diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt index bb64d97..4ad0718 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt @@ -11,19 +11,18 @@ import es.ffgiraldez.comicsearch.query.base.ui.results import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse @BindingAdapter("on_change") -fun bindQueryChangeListener( - search: FloatingSearchView, +fun FloatingSearchView.bindQueryChangeListener( listener: FloatingSearchView.OnQueryChangeListener -): Unit = search.setOnQueryChangeListener(listener) +): Unit = setOnQueryChangeListener(listener) @BindingAdapter("state_change") -fun bindSuggestions(search: FloatingSearchView, data: QueryViewState?): Unit? = data?.run { - search.toggleProgress(loading) - error.fold({ - results.map { ResultSuggestion(it) } +fun FloatingSearchView.bindSuggestions(data: QueryViewState?): Unit? = data?.let { state -> + toggleProgress(state.loading) + state.error.fold({ + state.results.map { ResultSuggestion(it) } }, { listOf(ErrorSuggestion(it.toHumanResponse())) - }).let { search.swapSuggestions(it) } + }).let(::swapSuggestions) } private fun FloatingSearchView.toggleProgress(show: Boolean): Unit = when (show) { diff --git a/app/src/test/java/es/ffgiraldez/comicsearch/comic/gen/EntitiesGen.kt b/app/src/test/java/es/ffgiraldez/comicsearch/comic/gen/EntitiesGen.kt index e477ad6..e4b0e2c 100644 --- a/app/src/test/java/es/ffgiraldez/comicsearch/comic/gen/EntitiesGen.kt +++ b/app/src/test/java/es/ffgiraldez/comicsearch/comic/gen/EntitiesGen.kt @@ -5,7 +5,9 @@ import es.ffgiraldez.comicsearch.comics.domain.ComicError import es.ffgiraldez.comicsearch.comics.domain.Volume import es.ffgiraldez.comicsearch.platform.left import es.ffgiraldez.comicsearch.platform.right +import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState import io.kotlintest.properties.Gen +import io.kotlintest.properties.filterIsInstance class ComicErrorGenerator : Gen { override fun constants(): Iterable = emptyList() @@ -38,6 +40,20 @@ class SuggestionGenerator : Gen>> { } } +class SuggestionViewStateGenerator : Gen> { + override fun constants(): Iterable> = listOf( + QueryViewState.idle(), + QueryViewState.loading() + ) + + override fun random(): Sequence> = Gen.suggestions().random().map { suggestion -> + suggestion.fold( + { QueryViewState.error(it) }, + { QueryViewState.result(it) } + ) + } +} + class VolumeGenerator : Gen { override fun constants(): Iterable = emptyList() @@ -67,9 +83,29 @@ class SearchGenerator : Gen>> { } } +class SearchViewStateGenerator : Gen> { + override fun constants(): Iterable> = listOf( + QueryViewState.idle(), + QueryViewState.loading() + ) + + override fun random(): Sequence> = Gen.search().random().map { search -> + search.fold( + { QueryViewState.error(it) }, + { QueryViewState.result(it) } + ) + } + +} fun Gen.Companion.suggestions(): Gen>> = SuggestionGenerator() +fun Gen.Companion.suggestionsViewState(): Gen> = SuggestionViewStateGenerator() + +fun Gen.Companion.suggestionsErrorViewState(): Gen = suggestionsViewState().filterIsInstance() + +fun Gen.Companion.suggestionsResultViewState(): Gen> = suggestionsViewState().filterIsInstance() + fun Gen.Companion.search(): Gen>> = SearchGenerator() fun Gen.Companion.comicError(): Gen = ComicErrorGenerator() @@ -77,3 +113,9 @@ fun Gen.Companion.comicError(): Gen = ComicErrorGenerator() fun Gen.Companion.query(): Gen = QueryGenerator() fun Gen.Companion.volume(): Gen = VolumeGenerator() + +fun Gen.Companion.searchViewState(): Gen> = SearchViewStateGenerator() + +fun Gen.Companion.searchErrorViewState(): Gen = searchViewState().filterIsInstance() + +fun Gen.Companion.searchResultViewState(): Gen> = searchViewState().filterIsInstance() \ No newline at end of file diff --git a/app/src/test/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapterSpec.kt b/app/src/test/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapterSpec.kt new file mode 100644 index 0000000..ee919d5 --- /dev/null +++ b/app/src/test/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapterSpec.kt @@ -0,0 +1,211 @@ +package es.ffgiraldez.comicsearch.query.search.ui + +import android.view.View +import android.widget.FrameLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.arlib.floatingsearchview.FloatingSearchView +import com.arlib.floatingsearchview.FloatingSearchView.OnSearchListener +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doNothing +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import es.ffgiraldez.comicsearch.comic.gen.searchErrorViewState +import es.ffgiraldez.comicsearch.comic.gen.searchViewState +import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState +import es.ffgiraldez.comicsearch.query.base.ui.OnVolumeSelectedListener +import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ErrorSuggestion +import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion +import es.ffgiraldez.comicsearch.query.base.ui.QueryVolumeAdapter +import es.ffgiraldez.comicsearch.query.base.ui.results +import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse +import io.kotlintest.properties.Gen +import io.kotlintest.properties.assertAll +import io.kotlintest.specs.WordSpec + +class SearchBindingAdapterSpec : WordSpec({ + "ProgressBar" should { + "not have interaction on null state" { + val progressBar = mock() + + progressBar.bindProgress(null) + + verifyZeroInteractions(progressBar) + } + + "be visible on loading state" { + val progressBar = mock() + + progressBar.bindProgress(QueryViewState.loading()) + + verify(progressBar).visibility = eq(View.VISIBLE) + } + + "be gone on non loading state" { + assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Loading }) { state -> + val progressBar = mock() + + progressBar.bindProgress(state) + + verify(progressBar).visibility = eq(View.GONE) + } + } + + } + + "TextView" should { + + "not have interaction on null state" { + val textView = mock() + + textView.bindErrorText(null) + + verifyZeroInteractions(textView) + } + + "not have interaction on non error state" { + assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state -> + val textView = mock() + + textView.bindErrorText(state) + + verifyZeroInteractions(textView) + } + } + + "show error human description on error state" { + assertAll(Gen.searchErrorViewState()) { state -> + val textView = mock() + + textView.bindErrorText(state) + + verify(textView).text = eq(state._error.toHumanResponse()) + } + } + } + + "FrameLayout" should { + "not have interaction on null state" { + val frameLayout = mock() + + frameLayout.bindStateVisibility(null) + + verifyZeroInteractions(frameLayout) + } + + "be gone on non error state" { + assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state -> + val frameLayout = mock() + + frameLayout.bindStateVisibility(state) + + + verify(frameLayout).visibility = eq(View.GONE) + } + } + + "be visible on error state" { + assertAll(Gen.searchErrorViewState()) { state -> + val frameLayout = mock() + + frameLayout.bindStateVisibility(state) + + + verify(frameLayout).visibility = eq(View.VISIBLE) + } + } + } + + "RecyclerView" should { + "configure is adapter when is not defined" { + val recyclerView = mock() + val adapter = mock() + val onVolumeSelected = mock() + + recyclerView.bindStateData(adapter, null, onVolumeSelected) + + verify(recyclerView).adapter = eq(adapter) + verify(adapter).onVolumeSelectedListener = eq(onVolumeSelected) + } + + "update result list on state change" { + assertAll(Gen.searchViewState()) { state -> + val adapter = mock() + val recyclerView = mock { + on { getAdapter() }.thenReturn(adapter) + } + val onVolumeSelected = mock() + + recyclerView.bindStateData(adapter, state, onVolumeSelected) + + verify(adapter).submitList(eq(state.results)) + } + } + + + "be visible on non error state" { + assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state -> + val adapter = mock() + val recyclerView = mock { + on { getAdapter() }.thenReturn(adapter) + } + val onVolumeSelected = mock() + + recyclerView.bindStateData(adapter, state, onVolumeSelected) + + verify(recyclerView).visibility = eq(View.VISIBLE) + } + } + + "be gone on non error state" { + assertAll(Gen.searchErrorViewState()) { state -> + val adapter = mock() + val recyclerView = mock { + on { getAdapter() }.thenReturn(adapter) + } + val onVolumeSelected = mock() + + recyclerView.bindStateData(adapter, state, onVolumeSelected) + + verify(recyclerView).visibility = eq(View.GONE) + } + } + } + + "FloatingView" should { + "avoid search on error suggestion click" { + val searchListenerCaptor = argumentCaptor() + val searchView = mock { + doNothing().whenever(it).setOnSearchListener(searchListenerCaptor.capture()) + } + val click = mock() + + + searchView.bindSuggestionClick(click, mock()) + + searchListenerCaptor.firstValue.onSuggestionClicked(ErrorSuggestion(Gen.string().random().first())) + + verifyZeroInteractions(click) + } + + "search on result suggestion click" { + val searchListenerCaptor = argumentCaptor() + val searchView = mock { + doNothing().whenever(it).setOnSearchListener(searchListenerCaptor.capture()) + } + val click = mock() + val suggestion = ResultSuggestion(Gen.string().random().first()) + + + searchView.bindSuggestionClick(click, mock()) + + searchListenerCaptor.firstValue.onSuggestionClicked(suggestion) + + verify(click).accept(eq(suggestion)) + } + } +}) diff --git a/app/src/test/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapterSpec.kt b/app/src/test/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapterSpec.kt new file mode 100644 index 0000000..e3f03c3 --- /dev/null +++ b/app/src/test/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapterSpec.kt @@ -0,0 +1,86 @@ +package es.ffgiraldez.comicsearch.query.sugestion.ui + +import com.arlib.floatingsearchview.FloatingSearchView +import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doNothing +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import es.ffgiraldez.comicsearch.comic.gen.suggestionsErrorViewState +import es.ffgiraldez.comicsearch.comic.gen.suggestionsResultViewState +import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState +import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse +import io.kotlintest.properties.Gen +import io.kotlintest.properties.assertAll +import io.kotlintest.specs.StringSpec +import org.junit.jupiter.api.Assertions.assertEquals + + +class SuggestionBindingAdapterSpec : StringSpec({ + "FloatingSearchView should show progress on loading state" { + val searchView = mock() + + searchView.bindSuggestions(QueryViewState.loading()) + + verify(searchView).showProgress() + + } + + "FloatingSearchView should hide progress on non loading state" { + val searchView = mock() + + searchView.bindSuggestions(QueryViewState.idle()) + + verify(searchView).hideProgress() + + } + + "FloatingSearchView should not have interaction on null state" { + val searchView = mock() + + searchView.bindSuggestions(null) + + verifyZeroInteractions(searchView) + + } + + "FloatingSearchView should show human description on error state" { + assertAll(Gen.suggestionsErrorViewState()) { state -> + val captor = argumentCaptor>() + val searchView = mock { + doNothing().whenever(it).swapSuggestions(captor.capture()) + } + + searchView.bindSuggestions(state) + + verify(searchView).swapSuggestions(any()) + + with(captor.firstValue) { + assertEquals(1, size) + assertEquals(state._error.toHumanResponse(), get(0).body) + } + } + } + + "FloatingSearchView should show results on result state" { + assertAll(Gen.suggestionsResultViewState()) { state -> + val captor = argumentCaptor>() + val searchView = mock { + doNothing().whenever(it).swapSuggestions(captor.capture()) + } + + searchView.bindSuggestions(state) + + verify(searchView).swapSuggestions(any()) + + with(captor.firstValue) { + assertEquals(state._results.size, size) + assertEquals(state._results, this.map { it.body }) + } + } + } +}) +