Skip to content

Commit 97ec65c

Browse files
authored
feat: ✅ create suggestion & search binding test suite (#29)
1 parent aeb4d59 commit 97ec65c

File tree

6 files changed

+367
-29
lines changed

6 files changed

+367
-29
lines changed

app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ sealed class QuerySearchSuggestion(
1212
data class ResultSuggestion(val volume: String) : QuerySearchSuggestion(volume)
1313

1414
@Parcelize
15-
data class ErrorSuggestion(val volume: String) : QuerySearchSuggestion(volume)
15+
data class ErrorSuggestion(val error: String) : QuerySearchSuggestion(error)
1616

1717
}

app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ interface ClickConsumer : Consumer<SearchSuggestion>
2626
interface SearchConsumer : Consumer<String>
2727

2828
@BindingAdapter("on_suggestion_click", "on_search", requireAll = false)
29-
fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer?, searchConsumer: SearchConsumer?) {
30-
search.setOnSearchListener(object : FloatingSearchView.OnSearchListener {
29+
fun FloatingSearchView.bindSuggestionClick(clickConsumer: ClickConsumer?, searchConsumer: SearchConsumer?) {
30+
setOnSearchListener(object : FloatingSearchView.OnSearchListener {
3131
override fun onSearchAction(currentQuery: String) {
3232
searchConsumer?.apply { searchConsumer.accept(currentQuery) }
3333
}
@@ -37,7 +37,7 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer
3737
when (searchSuggestion) {
3838
is ResultSuggestion -> {
3939
clickConsumer.accept(searchSuggestion)
40-
search.setSearchFocused(false)
40+
setSearchFocused(false)
4141
}
4242
}
4343
}
@@ -49,32 +49,32 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer
4949
* Limit scope to apply using RecyclerView as BindingAdapter
5050
*/
5151
@BindingAdapter("adapter", "state_change", "on_selected", requireAll = false)
52-
fun bindStateData(recycler: RecyclerView, inputAdapter: QueryVolumeAdapter, data: QueryViewState<Volume>?, consumer: OnVolumeSelectedListener) =
53-
with(recycler) {
54-
if (adapter == null) {
55-
inputAdapter.onVolumeSelectedListener = consumer
56-
adapter = inputAdapter
57-
}
52+
fun RecyclerView.bindStateData(inputAdapter: QueryVolumeAdapter, data: QueryViewState<Volume>?, consumer: OnVolumeSelectedListener) {
53+
if (adapter == null) {
54+
inputAdapter.onVolumeSelectedListener = consumer
55+
adapter = inputAdapter
56+
}
57+
58+
data?.let {
59+
bindError(data.error)
60+
bindResults(data.results)
61+
}
62+
}
5863

59-
data?.let {
60-
bindError(data.error)
61-
bindResults(data.results)
62-
}
63-
}
6464

6565
@BindingAdapter("state_change")
66-
fun bindStateVisibility(errorContainer: FrameLayout, data: QueryViewState<Volume>?) = data?.let { state ->
67-
state.error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it }
66+
fun FrameLayout.bindStateVisibility(data: QueryViewState<Volume>?) = data?.let { state ->
67+
state.error.fold({ View.GONE }, { View.VISIBLE }).let { visibility = it }
6868
}
6969

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

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

8080
private fun RecyclerView.bindError(error: Option<ComicError>): Unit =

app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,18 @@ import es.ffgiraldez.comicsearch.query.base.ui.results
1111
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
1212

1313
@BindingAdapter("on_change")
14-
fun bindQueryChangeListener(
15-
search: FloatingSearchView,
14+
fun FloatingSearchView.bindQueryChangeListener(
1615
listener: FloatingSearchView.OnQueryChangeListener
17-
): Unit = search.setOnQueryChangeListener(listener)
16+
): Unit = setOnQueryChangeListener(listener)
1817

1918
@BindingAdapter("state_change")
20-
fun bindSuggestions(search: FloatingSearchView, data: QueryViewState<String>?): Unit? = data?.run {
21-
search.toggleProgress(loading)
22-
error.fold({
23-
results.map { ResultSuggestion(it) }
19+
fun FloatingSearchView.bindSuggestions(data: QueryViewState<String>?): Unit? = data?.let { state ->
20+
toggleProgress(state.loading)
21+
state.error.fold({
22+
state.results.map { ResultSuggestion(it) }
2423
}, {
2524
listOf(ErrorSuggestion(it.toHumanResponse()))
26-
}).let { search.swapSuggestions(it) }
25+
}).let(::swapSuggestions)
2726
}
2827

2928
private fun FloatingSearchView.toggleProgress(show: Boolean): Unit = when (show) {

app/src/test/java/es/ffgiraldez/comicsearch/comic/gen/EntitiesGen.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import es.ffgiraldez.comicsearch.comics.domain.ComicError
55
import es.ffgiraldez.comicsearch.comics.domain.Volume
66
import es.ffgiraldez.comicsearch.platform.left
77
import es.ffgiraldez.comicsearch.platform.right
8+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
89
import io.kotlintest.properties.Gen
10+
import io.kotlintest.properties.filterIsInstance
911

1012
class ComicErrorGenerator : Gen<ComicError> {
1113
override fun constants(): Iterable<ComicError> = emptyList()
@@ -38,6 +40,20 @@ class SuggestionGenerator : Gen<Either<ComicError, List<String>>> {
3840
}
3941
}
4042

43+
class SuggestionViewStateGenerator : Gen<QueryViewState<String>> {
44+
override fun constants(): Iterable<QueryViewState<String>> = listOf(
45+
QueryViewState.idle(),
46+
QueryViewState.loading()
47+
)
48+
49+
override fun random(): Sequence<QueryViewState<String>> = Gen.suggestions().random().map { suggestion ->
50+
suggestion.fold(
51+
{ QueryViewState.error<String>(it) },
52+
{ QueryViewState.result(it) }
53+
)
54+
}
55+
}
56+
4157
class VolumeGenerator : Gen<Volume> {
4258
override fun constants(): Iterable<Volume> = emptyList()
4359

@@ -67,13 +83,39 @@ class SearchGenerator : Gen<Either<ComicError, List<Volume>>> {
6783
}
6884
}
6985

86+
class SearchViewStateGenerator : Gen<QueryViewState<Volume>> {
87+
override fun constants(): Iterable<QueryViewState<Volume>> = listOf(
88+
QueryViewState.idle(),
89+
QueryViewState.loading()
90+
)
91+
92+
override fun random(): Sequence<QueryViewState<Volume>> = Gen.search().random().map { search ->
93+
search.fold(
94+
{ QueryViewState.error<Volume>(it) },
95+
{ QueryViewState.result(it) }
96+
)
97+
}
98+
99+
}
70100

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

103+
fun Gen.Companion.suggestionsViewState(): Gen<QueryViewState<String>> = SuggestionViewStateGenerator()
104+
105+
fun Gen.Companion.suggestionsErrorViewState(): Gen<QueryViewState.Error> = suggestionsViewState().filterIsInstance()
106+
107+
fun Gen.Companion.suggestionsResultViewState(): Gen<QueryViewState.Result<String>> = suggestionsViewState().filterIsInstance()
108+
73109
fun Gen.Companion.search(): Gen<Either<ComicError, List<Volume>>> = SearchGenerator()
74110

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

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

79115
fun Gen.Companion.volume(): Gen<Volume> = VolumeGenerator()
116+
117+
fun Gen.Companion.searchViewState(): Gen<QueryViewState<Volume>> = SearchViewStateGenerator()
118+
119+
fun Gen.Companion.searchErrorViewState(): Gen<QueryViewState.Error> = searchViewState().filterIsInstance()
120+
121+
fun Gen.Companion.searchResultViewState(): Gen<QueryViewState.Result<Volume>> = searchViewState().filterIsInstance()
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package es.ffgiraldez.comicsearch.query.search.ui
2+
3+
import android.view.View
4+
import android.widget.FrameLayout
5+
import android.widget.ProgressBar
6+
import android.widget.TextView
7+
import androidx.recyclerview.widget.RecyclerView
8+
import com.arlib.floatingsearchview.FloatingSearchView
9+
import com.arlib.floatingsearchview.FloatingSearchView.OnSearchListener
10+
import com.nhaarman.mockitokotlin2.argumentCaptor
11+
import com.nhaarman.mockitokotlin2.doNothing
12+
import com.nhaarman.mockitokotlin2.eq
13+
import com.nhaarman.mockitokotlin2.mock
14+
import com.nhaarman.mockitokotlin2.verify
15+
import com.nhaarman.mockitokotlin2.verifyZeroInteractions
16+
import com.nhaarman.mockitokotlin2.whenever
17+
import es.ffgiraldez.comicsearch.comic.gen.searchErrorViewState
18+
import es.ffgiraldez.comicsearch.comic.gen.searchViewState
19+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
20+
import es.ffgiraldez.comicsearch.query.base.ui.OnVolumeSelectedListener
21+
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ErrorSuggestion
22+
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
23+
import es.ffgiraldez.comicsearch.query.base.ui.QueryVolumeAdapter
24+
import es.ffgiraldez.comicsearch.query.base.ui.results
25+
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
26+
import io.kotlintest.properties.Gen
27+
import io.kotlintest.properties.assertAll
28+
import io.kotlintest.specs.WordSpec
29+
30+
class SearchBindingAdapterSpec : WordSpec({
31+
"ProgressBar" should {
32+
"not have interaction on null state" {
33+
val progressBar = mock<ProgressBar>()
34+
35+
progressBar.bindProgress(null)
36+
37+
verifyZeroInteractions(progressBar)
38+
}
39+
40+
"be visible on loading state" {
41+
val progressBar = mock<ProgressBar>()
42+
43+
progressBar.bindProgress(QueryViewState.loading())
44+
45+
verify(progressBar).visibility = eq(View.VISIBLE)
46+
}
47+
48+
"be gone on non loading state" {
49+
assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Loading }) { state ->
50+
val progressBar = mock<ProgressBar>()
51+
52+
progressBar.bindProgress(state)
53+
54+
verify(progressBar).visibility = eq(View.GONE)
55+
}
56+
}
57+
58+
}
59+
60+
"TextView" should {
61+
62+
"not have interaction on null state" {
63+
val textView = mock<TextView>()
64+
65+
textView.bindErrorText(null)
66+
67+
verifyZeroInteractions(textView)
68+
}
69+
70+
"not have interaction on non error state" {
71+
assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state ->
72+
val textView = mock<TextView>()
73+
74+
textView.bindErrorText(state)
75+
76+
verifyZeroInteractions(textView)
77+
}
78+
}
79+
80+
"show error human description on error state" {
81+
assertAll(Gen.searchErrorViewState()) { state ->
82+
val textView = mock<TextView>()
83+
84+
textView.bindErrorText(state)
85+
86+
verify(textView).text = eq(state._error.toHumanResponse())
87+
}
88+
}
89+
}
90+
91+
"FrameLayout" should {
92+
"not have interaction on null state" {
93+
val frameLayout = mock<FrameLayout>()
94+
95+
frameLayout.bindStateVisibility(null)
96+
97+
verifyZeroInteractions(frameLayout)
98+
}
99+
100+
"be gone on non error state" {
101+
assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state ->
102+
val frameLayout = mock<FrameLayout>()
103+
104+
frameLayout.bindStateVisibility(state)
105+
106+
107+
verify(frameLayout).visibility = eq(View.GONE)
108+
}
109+
}
110+
111+
"be visible on error state" {
112+
assertAll(Gen.searchErrorViewState()) { state ->
113+
val frameLayout = mock<FrameLayout>()
114+
115+
frameLayout.bindStateVisibility(state)
116+
117+
118+
verify(frameLayout).visibility = eq(View.VISIBLE)
119+
}
120+
}
121+
}
122+
123+
"RecyclerView" should {
124+
"configure is adapter when is not defined" {
125+
val recyclerView = mock<RecyclerView>()
126+
val adapter = mock<QueryVolumeAdapter>()
127+
val onVolumeSelected = mock<OnVolumeSelectedListener>()
128+
129+
recyclerView.bindStateData(adapter, null, onVolumeSelected)
130+
131+
verify(recyclerView).adapter = eq(adapter)
132+
verify(adapter).onVolumeSelectedListener = eq(onVolumeSelected)
133+
}
134+
135+
"update result list on state change" {
136+
assertAll(Gen.searchViewState()) { state ->
137+
val adapter = mock<QueryVolumeAdapter>()
138+
val recyclerView = mock<RecyclerView> {
139+
on { getAdapter() }.thenReturn(adapter)
140+
}
141+
val onVolumeSelected = mock<OnVolumeSelectedListener>()
142+
143+
recyclerView.bindStateData(adapter, state, onVolumeSelected)
144+
145+
verify(adapter).submitList(eq(state.results))
146+
}
147+
}
148+
149+
150+
"be visible on non error state" {
151+
assertAll(Gen.searchViewState().filterNot { it is QueryViewState.Error }) { state ->
152+
val adapter = mock<QueryVolumeAdapter>()
153+
val recyclerView = mock<RecyclerView> {
154+
on { getAdapter() }.thenReturn(adapter)
155+
}
156+
val onVolumeSelected = mock<OnVolumeSelectedListener>()
157+
158+
recyclerView.bindStateData(adapter, state, onVolumeSelected)
159+
160+
verify(recyclerView).visibility = eq(View.VISIBLE)
161+
}
162+
}
163+
164+
"be gone on non error state" {
165+
assertAll(Gen.searchErrorViewState()) { state ->
166+
val adapter = mock<QueryVolumeAdapter>()
167+
val recyclerView = mock<RecyclerView> {
168+
on { getAdapter() }.thenReturn(adapter)
169+
}
170+
val onVolumeSelected = mock<OnVolumeSelectedListener>()
171+
172+
recyclerView.bindStateData(adapter, state, onVolumeSelected)
173+
174+
verify(recyclerView).visibility = eq(View.GONE)
175+
}
176+
}
177+
}
178+
179+
"FloatingView" should {
180+
"avoid search on error suggestion click" {
181+
val searchListenerCaptor = argumentCaptor<OnSearchListener>()
182+
val searchView = mock<FloatingSearchView> {
183+
doNothing().whenever(it).setOnSearchListener(searchListenerCaptor.capture())
184+
}
185+
val click = mock<ClickConsumer>()
186+
187+
188+
searchView.bindSuggestionClick(click, mock())
189+
190+
searchListenerCaptor.firstValue.onSuggestionClicked(ErrorSuggestion(Gen.string().random().first()))
191+
192+
verifyZeroInteractions(click)
193+
}
194+
195+
"search on result suggestion click" {
196+
val searchListenerCaptor = argumentCaptor<OnSearchListener>()
197+
val searchView = mock<FloatingSearchView> {
198+
doNothing().whenever(it).setOnSearchListener(searchListenerCaptor.capture())
199+
}
200+
val click = mock<ClickConsumer>()
201+
val suggestion = ResultSuggestion(Gen.string().random().first())
202+
203+
204+
searchView.bindSuggestionClick(click, mock())
205+
206+
searchListenerCaptor.firstValue.onSuggestionClicked(suggestion)
207+
208+
verify(click).accept(eq(suggestion))
209+
}
210+
}
211+
})

0 commit comments

Comments
 (0)