Skip to content

Commit

Permalink
feat: ✅ create suggestion & search binding test suite (ffgiraldez#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
ffgiraldez authored Mar 3, 2019
1 parent aeb4d59 commit 97ec65c
Show file tree
Hide file tree
Showing 6 changed files with 367 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ interface ClickConsumer : Consumer<SearchSuggestion>
interface SearchConsumer : Consumer<String>

@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) }
}
Expand All @@ -37,7 +37,7 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer
when (searchSuggestion) {
is ResultSuggestion -> {
clickConsumer.accept(searchSuggestion)
search.setSearchFocused(false)
setSearchFocused(false)
}
}
}
Expand All @@ -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<Volume>?, consumer: OnVolumeSelectedListener) =
with(recycler) {
if (adapter == null) {
inputAdapter.onVolumeSelectedListener = consumer
adapter = inputAdapter
}
fun RecyclerView.bindStateData(inputAdapter: QueryVolumeAdapter, data: QueryViewState<Volume>?, 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<Volume>?) = data?.let { state ->
state.error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it }
fun FrameLayout.bindStateVisibility(data: QueryViewState<Volume>?) = data?.let { state ->
state.error.fold({ View.GONE }, { View.VISIBLE }).let { visibility = it }
}

@BindingAdapter("state_change")
fun bindErrorText(errorText: TextView, data: QueryViewState<Volume>?) = data?.let { state ->
state.error.fold({ Unit }, { errorText.text = it.toHumanResponse() })
fun TextView.bindErrorText(data: QueryViewState<Volume>?) = data?.let { state ->
state.error.fold({ Unit }, { text = it.toHumanResponse() })
}

@BindingAdapter("state_change")
fun bindProgress(progress: ProgressBar, data: QueryViewState<Volume>?) = data?.let { state ->
progress.gone(!state.loading)
fun ProgressBar.bindProgress(data: QueryViewState<Volume>?) = data?.let { state ->
gone(!state.loading)
}

private fun RecyclerView.bindError(error: Option<ComicError>): Unit =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>?): Unit? = data?.run {
search.toggleProgress(loading)
error.fold({
results.map { ResultSuggestion(it) }
fun FloatingSearchView.bindSuggestions(data: QueryViewState<String>?): 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComicError> {
override fun constants(): Iterable<ComicError> = emptyList()
Expand Down Expand Up @@ -38,6 +40,20 @@ class SuggestionGenerator : Gen<Either<ComicError, List<String>>> {
}
}

class SuggestionViewStateGenerator : Gen<QueryViewState<String>> {
override fun constants(): Iterable<QueryViewState<String>> = listOf(
QueryViewState.idle(),
QueryViewState.loading()
)

override fun random(): Sequence<QueryViewState<String>> = Gen.suggestions().random().map { suggestion ->
suggestion.fold(
{ QueryViewState.error<String>(it) },
{ QueryViewState.result(it) }
)
}
}

class VolumeGenerator : Gen<Volume> {
override fun constants(): Iterable<Volume> = emptyList()

Expand Down Expand Up @@ -67,13 +83,39 @@ class SearchGenerator : Gen<Either<ComicError, List<Volume>>> {
}
}

class SearchViewStateGenerator : Gen<QueryViewState<Volume>> {
override fun constants(): Iterable<QueryViewState<Volume>> = listOf(
QueryViewState.idle(),
QueryViewState.loading()
)

override fun random(): Sequence<QueryViewState<Volume>> = Gen.search().random().map { search ->
search.fold(
{ QueryViewState.error<Volume>(it) },
{ QueryViewState.result(it) }
)
}

}

fun Gen.Companion.suggestions(): Gen<Either<ComicError, List<String>>> = SuggestionGenerator()

fun Gen.Companion.suggestionsViewState(): Gen<QueryViewState<String>> = SuggestionViewStateGenerator()

fun Gen.Companion.suggestionsErrorViewState(): Gen<QueryViewState.Error> = suggestionsViewState().filterIsInstance()

fun Gen.Companion.suggestionsResultViewState(): Gen<QueryViewState.Result<String>> = suggestionsViewState().filterIsInstance()

fun Gen.Companion.search(): Gen<Either<ComicError, List<Volume>>> = SearchGenerator()

fun Gen.Companion.comicError(): Gen<ComicError> = ComicErrorGenerator()

fun Gen.Companion.query(): Gen<String> = QueryGenerator()

fun Gen.Companion.volume(): Gen<Volume> = VolumeGenerator()

fun Gen.Companion.searchViewState(): Gen<QueryViewState<Volume>> = SearchViewStateGenerator()

fun Gen.Companion.searchErrorViewState(): Gen<QueryViewState.Error> = searchViewState().filterIsInstance()

fun Gen.Companion.searchResultViewState(): Gen<QueryViewState.Result<Volume>> = searchViewState().filterIsInstance()
Original file line number Diff line number Diff line change
@@ -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>()

progressBar.bindProgress(null)

verifyZeroInteractions(progressBar)
}

"be visible on loading state" {
val progressBar = mock<ProgressBar>()

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>()

progressBar.bindProgress(state)

verify(progressBar).visibility = eq(View.GONE)
}
}

}

"TextView" should {

"not have interaction on null state" {
val textView = mock<TextView>()

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>()

textView.bindErrorText(state)

verifyZeroInteractions(textView)
}
}

"show error human description on error state" {
assertAll(Gen.searchErrorViewState()) { state ->
val textView = mock<TextView>()

textView.bindErrorText(state)

verify(textView).text = eq(state._error.toHumanResponse())
}
}
}

"FrameLayout" should {
"not have interaction on null state" {
val frameLayout = mock<FrameLayout>()

frameLayout.bindStateVisibility(null)

verifyZeroInteractions(frameLayout)
}

"be gone on non error state" {
assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state ->
val frameLayout = mock<FrameLayout>()

frameLayout.bindStateVisibility(state)


verify(frameLayout).visibility = eq(View.GONE)
}
}

"be visible on error state" {
assertAll(Gen.searchErrorViewState()) { state ->
val frameLayout = mock<FrameLayout>()

frameLayout.bindStateVisibility(state)


verify(frameLayout).visibility = eq(View.VISIBLE)
}
}
}

"RecyclerView" should {
"configure is adapter when is not defined" {
val recyclerView = mock<RecyclerView>()
val adapter = mock<QueryVolumeAdapter>()
val onVolumeSelected = mock<OnVolumeSelectedListener>()

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<QueryVolumeAdapter>()
val recyclerView = mock<RecyclerView> {
on { getAdapter() }.thenReturn(adapter)
}
val onVolumeSelected = mock<OnVolumeSelectedListener>()

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<QueryVolumeAdapter>()
val recyclerView = mock<RecyclerView> {
on { getAdapter() }.thenReturn(adapter)
}
val onVolumeSelected = mock<OnVolumeSelectedListener>()

recyclerView.bindStateData(adapter, state, onVolumeSelected)

verify(recyclerView).visibility = eq(View.VISIBLE)
}
}

"be gone on non error state" {
assertAll(Gen.searchErrorViewState()) { state ->
val adapter = mock<QueryVolumeAdapter>()
val recyclerView = mock<RecyclerView> {
on { getAdapter() }.thenReturn(adapter)
}
val onVolumeSelected = mock<OnVolumeSelectedListener>()

recyclerView.bindStateData(adapter, state, onVolumeSelected)

verify(recyclerView).visibility = eq(View.GONE)
}
}
}

"FloatingView" should {
"avoid search on error suggestion click" {
val searchListenerCaptor = argumentCaptor<OnSearchListener>()
val searchView = mock<FloatingSearchView> {
doNothing().whenever(it).setOnSearchListener(searchListenerCaptor.capture())
}
val click = mock<ClickConsumer>()


searchView.bindSuggestionClick(click, mock())

searchListenerCaptor.firstValue.onSuggestionClicked(ErrorSuggestion(Gen.string().random().first()))

verifyZeroInteractions(click)
}

"search on result suggestion click" {
val searchListenerCaptor = argumentCaptor<OnSearchListener>()
val searchView = mock<FloatingSearchView> {
doNothing().whenever(it).setOnSearchListener(searchListenerCaptor.capture())
}
val click = mock<ClickConsumer>()
val suggestion = ResultSuggestion(Gen.string().random().first())


searchView.bindSuggestionClick(click, mock())

searchListenerCaptor.firstValue.onSuggestionClicked(suggestion)

verify(click).accept(eq(suggestion))
}
}
})
Loading

0 comments on commit 97ec65c

Please sign in to comment.