diff --git a/client/android/div/src/main/java/com/yandex/div/core/expression/variables/TwoWayVariableBinder.kt b/client/android/div/src/main/java/com/yandex/div/core/expression/variables/TwoWayVariableBinder.kt index 47dd9ae0b..e31a6e089 100644 --- a/client/android/div/src/main/java/com/yandex/div/core/expression/variables/TwoWayVariableBinder.kt +++ b/client/android/div/src/main/java/com/yandex/div/core/expression/variables/TwoWayVariableBinder.kt @@ -2,6 +2,7 @@ package com.yandex.div.core.expression.variables import androidx.annotation.MainThread import com.yandex.div.core.Disposable +import com.yandex.div.core.annotations.Mockable import com.yandex.div.core.dagger.DivScope import com.yandex.div.core.expression.ExpressionsRuntimeProvider import com.yandex.div.core.view2.Div2View @@ -10,6 +11,7 @@ import com.yandex.div.data.Variable import javax.inject.Inject @DivScope +@Mockable internal class TwoWayStringVariableBinder @Inject constructor( errorCollectors: ErrorCollectors, expressionsRuntimeProvider: ExpressionsRuntimeProvider @@ -21,6 +23,7 @@ internal class TwoWayStringVariableBinder @Inject constructor( } @DivScope +@Mockable internal class TwoWayIntegerVariableBinder @Inject constructor( errorCollectors: ErrorCollectors, expressionsRuntimeProvider: ExpressionsRuntimeProvider @@ -31,11 +34,13 @@ internal class TwoWayIntegerVariableBinder @Inject constructor( override fun Long.toStringValue() = toString() } +@Mockable internal abstract class TwoWayVariableBinder( private val errorCollectors: ErrorCollectors, private val expressionsRuntimeProvider: ExpressionsRuntimeProvider ) { + @Mockable interface Callbacks { @MainThread fun onVariableChanged(value: T?) diff --git a/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivSelectBinder.kt b/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivSelectBinder.kt index 4ed84ce2f..564db2d72 100644 --- a/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivSelectBinder.kt +++ b/client/android/div/src/main/java/com/yandex/div/core/view2/divs/DivSelectBinder.kt @@ -87,7 +87,7 @@ internal class DivSelectBinder @Inject constructor( private fun DivSelectView.observeVariable(div: DivSelect, divView: Div2View, errorCollector: ErrorCollector) { val resolver = divView.expressionResolver - variableBinder.bindVariable( + val subscription = variableBinder.bindVariable( divView, div.valueVariable, callbacks = object : TwoWayStringVariableBinder.Callbacks { @@ -116,6 +116,8 @@ internal class DivSelectBinder @Inject constructor( this@observeVariable.valueUpdater = valueUpdater } }) + + addSubscription(subscription) } private fun DivSelectView.observeFontSize(div: DivSelect, resolver: ExpressionResolver) { diff --git a/client/android/div/src/test/java/com/yandex/div/core/view2/divs/DivSelectBinderTest.kt b/client/android/div/src/test/java/com/yandex/div/core/view2/divs/DivSelectBinderTest.kt new file mode 100644 index 000000000..b9dcc5511 --- /dev/null +++ b/client/android/div/src/test/java/com/yandex/div/core/view2/divs/DivSelectBinderTest.kt @@ -0,0 +1,112 @@ +package com.yandex.div.core.view2.divs + +import com.yandex.div.core.expression.variables.TwoWayStringVariableBinder +import com.yandex.div.core.expression.variables.TwoWayVariableBinder +import com.yandex.div.core.view2.DivTypefaceResolver +import com.yandex.div.core.view2.divs.widgets.DivSelectView +import com.yandex.div.core.view2.errors.ErrorCollectors +import com.yandex.div.internal.widget.SelectView +import com.yandex.div.json.expressions.ExpressionResolver +import com.yandex.div2.Div +import com.yandex.div2.DivSelect +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DivSelectBinderTest : DivBinderTest() { + private val divTypefaceResolver = mock() + private val variableBinder = mock() { + on { bindVariable(any(), any(), any()) } doReturn mock() + } + private val errorCollectors = mock { + on { getOrCreate(anyOrNull(), anyOrNull()) } doReturn mock() + } + private val captor = argumentCaptor>() + + private val underTest = DivSelectBinder( + baseBinder = baseBinder, + typefaceResolver = divTypefaceResolver, + variableBinder = variableBinder, + errorCollectors = errorCollectors + ) + + private val div = UnitTestData(SELECT_DIR, "with_options.json").div as Div.Select + private val divSelect = div.value + private val view = (viewCreator.create(div, ExpressionResolver.EMPTY) as DivSelectView).apply { + layoutParams = defaultLayoutParams() + } + + @Test + fun `bind value_variable`() { + underTest.bindView(view, divSelect, divView) + + verify(variableBinder).bindVariable(any(), eq(divSelect.valueVariable), any()) + verifyNoMoreInteractions(variableBinder) + } + + @Test + fun `update text after variable changed`() { + underTest.bindView(view, divSelect, divView) + verify(variableBinder).bindVariable(any(), any(), captor.capture()) + + val (optionText, optionValue) = divSelect.options.evaluateLastOption() + + view.assertTextApplied(optionText) { + val callbacks = captor.allValues.single() + callbacks.onVariableChanged(optionValue) + } + + verifyNoMoreInteractions(variableBinder) + } + + @Test + fun `update text and variable after option selected`() { + val viewStateChangeListener = mock<(String) -> Unit>() + + underTest.bindView(view, divSelect, divView) + verify(variableBinder).bindVariable(any(), any(), captor.capture()) + + val (optionText, optionValue) = divSelect.options.evaluateLastOption() + + view.assertTextApplied(optionText) { + val callbacks = captor.allValues.single() + callbacks.setViewStateChangeListener(viewStateChangeListener) + + view.onItemSelectedListener!!(divSelect.options.size - 1) + } + + verify(viewStateChangeListener).invoke(optionValue) + verifyNoMoreInteractions(viewStateChangeListener) + + verifyNoMoreInteractions(variableBinder) + } + + private fun List.evaluateLastOption(): Pair { + val option = last() + + val optionValue = option.value.evaluate(divView.expressionResolver) + val optionText = option.text?.evaluate(divView.expressionResolver) ?: optionValue + + return optionText to optionValue + } + + private inline fun SelectView.assertTextApplied(expectedText: String, body: () -> Unit) { + Assert.assertNotEquals(text, expectedText) + body() + Assert.assertEquals(text, expectedText) + } + + companion object { + private const val SELECT_DIR = "div-select" + } +} diff --git a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivSelectPopupVisibilityTest.kt b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivSelectPopupVisibilityTest.kt new file mode 100644 index 000000000..11291f318 --- /dev/null +++ b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/DivSelectPopupVisibilityTest.kt @@ -0,0 +1,127 @@ +package com.yandex.div + +import androidx.test.espresso.Espresso.pressBack +import androidx.test.rule.ActivityTestRule +import com.yandex.div.rule.uiTestRule +import com.yandex.div.steps.divSelect +import com.yandex.divkit.demo.DummyActivity +import com.yandex.test.util.assertNoPopupsAreDisplayed +import com.yandex.test.util.assertPopupDisplayed +import org.junit.Rule +import org.junit.Test + + +class DivSelectPopupVisibilityTest { + + private val activityRule = ActivityTestRule(DummyActivity::class.java) + + @get:Rule + val rule = uiTestRule { activityRule } + + @Test + fun popupShown() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + clickOnSelect() + assertPopupDisplayed() + } + } + + @Test + fun popupDismissedOnItemTap() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + clickOnSelect() + assertPopupDisplayed() + + selectFirstOption() + assertNoPopupsAreDisplayed() + } + } + + @Test + fun popupDismissedOnPressBack() { + // Press back should work the same as click outside of popup + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + clickOnSelect() + assertPopupDisplayed() + + pressBack() + assertNoPopupsAreDisplayed() + } + } + + @Test + fun popupDismissedOnVisibilityInvisible() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + openSelectWithDelayedAction("visibility_invisible") + + assertNoPopupsAreDisplayed() + } + } + + @Test + fun popupDismissedOnVisibilityGone() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + openSelectWithDelayedAction("visibility_gone") + + assertNoPopupsAreDisplayed() + } + } + + @Test + fun popupAlignedWithAnchor() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + val select = stealSelect() + + clickOnSelect() + assertPopupDisplayed() + + assert { popupAlignedWithAnchor(select) } + } + } + + @Test + fun popupAlignedWithAnchorAfterWidthChange() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + val select = stealSelect() + + openSelectWithDelayedAction("width_change") + + assert { popupAlignedWithAnchor(select) } + } + } + + @Test + fun popupAlignedWithAnchorAfterPositionChange() { + divSelect { + activityRule.buildContainer() + assertNoPopupsAreDisplayed() + + val select = stealSelect() + + openSelectWithDelayedAction("position_change") + + assert { popupAlignedWithAnchor(select) } + } + } +} diff --git a/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivSelectSteps.kt b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivSelectSteps.kt new file mode 100644 index 000000000..46e14f1cc --- /dev/null +++ b/client/android/divkit-demo-app/src/androidTest/java/com/yandex/div/steps/DivSelectSteps.kt @@ -0,0 +1,100 @@ +package com.yandex.div.steps + +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.TextView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingResource +import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.ActivityTestRule +import com.yandex.div.view.click +import com.yandex.div.view.tap +import com.yandex.divkit.demo.DummyActivity +import com.yandex.test.idling.SimpleIdlingResource +import com.yandex.test.idling.waitForIdlingResource +import com.yandex.test.util.Report.step +import com.yandex.test.util.StepsDsl +import com.yandex.test.util.assertPopupDisplayed +import org.hamcrest.Matchers + +private const val TIMER_FINISHED_TEXT = "DONE" + +internal fun divSelect(f: DivSelectSteps.() -> Unit) = f(DivSelectSteps()) + +@StepsDsl +class DivSelectSteps : DivTestAssetSteps() { + private val selectInteraction = onView(ViewMatchers.withHint("Select country")) + private val firstItemInteraction = onView(withText("United Kingdom")) + private val timerInteraction = onView(Matchers.anyOf(withText("IN PROGRESS"))) + + fun ActivityTestRule.buildContainer() { + testAsset = "ui_test_data/select/select_with_default.json" + buildContainer(MATCH_PARENT, MATCH_PARENT) + } + + fun clickOnSelect(): Unit = step("Click on select") { + selectInteraction.click() + } + + fun selectFirstOption(): Unit = step("Select first option") { + firstItemInteraction.tap() + } + + + fun openSelectWithDelayedAction(action: String) = step("Open select with delayed action") { + val timer = prepareTimerIdlingResource() + invokeDelayedAction(action) + + clickOnSelect() + assertPopupDisplayed() + + waitForIdlingResource(timer) + } + + fun stealSelect(): View = step("Steal select view") { + selectInteraction.stealView() + } + + private fun invokeDelayedAction(action: String): Unit = + step("Invoke action $action with delay") { + onView(withText(action)).click() + } + + private fun prepareTimerIdlingResource(): IdlingResource = + step("Prepare timer idling resource") { + timerInteraction.stealView().asIdlingResource { + (this as TextView).text == TIMER_FINISHED_TEXT + } + } + + private inline fun View.asIdlingResource(crossinline checkIdle: View.() -> Boolean) = + object : SimpleIdlingResource() { + override fun checkIdle(): Boolean = checkIdle(this@asIdlingResource) + } + + private fun ViewInteraction.stealView(): View { + var stolenView: View? = null + check { view, _ -> stolenView = view } + return stolenView!! + } + + fun assert(f: DivSelectAssertions.() -> Unit) = f(DivSelectAssertions()) +} + +@StepsDsl +class DivSelectAssertions { + private val popupInteraction = onView(ViewMatchers.withChild(withText("United Kingdom"))) + + fun popupAlignedWithAnchor(anchorView: View) { + popupInteraction.check { popup, _ -> + val (left, top) = IntArray(2).apply { popup.getLocationOnScreen(this) } + val (anchorLeft, anchorTop) = IntArray(2).apply { anchorView.getLocationOnScreen(this) } + + assert(left == anchorLeft) + assert(top == anchorTop) + assert(popup.width == anchorView.width) + } + } +} diff --git a/client/android/ui-test-common/src/main/java/com/yandex/test/util/Popups.kt b/client/android/ui-test-common/src/main/java/com/yandex/test/util/Popups.kt index 0c6f2f659..a4394e55c 100644 --- a/client/android/ui-test-common/src/main/java/com/yandex/test/util/Popups.kt +++ b/client/android/ui-test-common/src/main/java/com/yandex/test/util/Popups.kt @@ -12,6 +12,12 @@ fun assertNoPopupsAreDisplayed() { activeRoots.none { RootMatchers.isPlatformPopup().matches(it) }) } +fun assertPopupDisplayed() { + Assert.assertNotNull("Exactly one popup view must exist", + activeRoots.singleOrNull { RootMatchers.isPlatformPopup().matches(it) } + ) +} + internal val activeRoots: List get() = performOnMain { Reflection.staticField("BASE") diff --git a/client/web/divkit/tests/hermione/screens/crossplatform/unit/div-select/with_options/chromeMobile/with_options.png b/client/web/divkit/tests/hermione/screens/crossplatform/unit/div-select/with_options/chromeMobile/with_options.png new file mode 100644 index 000000000..33553e30d Binary files /dev/null and b/client/web/divkit/tests/hermione/screens/crossplatform/unit/div-select/with_options/chromeMobile/with_options.png differ diff --git a/client/web/divkit/tests/hermione/screens/crossplatform/unit/div-select/with_options/firefoxMobile/with_options.png b/client/web/divkit/tests/hermione/screens/crossplatform/unit/div-select/with_options/firefoxMobile/with_options.png new file mode 100644 index 000000000..a9035acc4 Binary files /dev/null and b/client/web/divkit/tests/hermione/screens/crossplatform/unit/div-select/with_options/firefoxMobile/with_options.png differ diff --git a/test_data/ui_test_data/select/select_with_default.json b/test_data/ui_test_data/select/select_with_default.json new file mode 100644 index 000000000..72d56845a --- /dev/null +++ b/test_data/ui_test_data/select/select_with_default.json @@ -0,0 +1,240 @@ +{ + "templates": { + "button": { + "type": "text", + "paddings": { + "bottom": 16, + "top": 16, + "left": 16, + "right": 16 + } + } + }, + "card": { + "log_id": "select", + "variables": [ + { + "name": "value_variable", + "type": "string", + "value": "" + }, + { + "name": "visibility", + "type": "string", + "value": "visible" + }, + { + "name": "timer_finished", + "type": "string", + "value": "IN PROGRESS" + }, + { + "name": "width", + "type": "integer", + "value": 200 + }, + { + "name": "margin", + "type": "integer", + "value": 16 + } + ], + "timers": [ + { + "id": "visibility_invisible", + "duration": 1000, + "end_actions": [ + { + "log_id": "end", + "url": "div-action://set_variable?name=visibility&value=invisible" + }, + { + "log_id": "end", + "url": "div-action://set_variable?name=timer_finished&value=DONE" + } + ] + }, + { + "id": "visibility_gone", + "duration": 1000, + "end_actions": [ + { + "log_id": "end", + "url": "div-action://set_variable?name=visibility&value=gone" + }, + { + "log_id": "end", + "url": "div-action://set_variable?name=timer_finished&value=DONE" + } + ] + }, + { + "id": "width_change", + "duration": 1000, + "end_actions": [ + { + "log_id": "end", + "url": "div-action://set_variable?name=width&value=150" + }, + { + "log_id": "end", + "url": "div-action://set_variable?name=timer_finished&value=DONE" + } + ] + }, + { + "id": "position_change", + "duration": 1000, + "end_actions": [ + { + "log_id": "end", + "url": "div-action://set_variable?name=margin&value=64" + }, + { + "log_id": "end", + "url": "div-action://set_variable?name=timer_finished&value=DONE" + } + ] + } + ], + "states": [ + { + "state_id": 0, + "div": { + "type": "container", + "width": { + "type": "match_parent" + }, + "height": { + "type": "wrap_content" + }, + "items": [ + { + "type": "text", + "text": "@{timer_finished}" + }, + { + "type": "select", + "width": { + "type": "fixed", + "value": "@{width}" + }, + "height": { + "type": "wrap_content" + }, + "margins": { + "left": "@{margin}", + "top": "@{margin}" + }, + "paddings": { + "left": 16, + "top": 10, + "right": 16, + "bottom": 10 + }, + "alpha": 1, + "alignment_horizontal": "center", + "alignment_vertical": "center", + "background": [ + { + "type": "solid", + "color": "#0e000000" + } + ], + "border": { + "corner_radius": 8 + }, + "font_size": 16, + "font_weight": "medium", + "text_color": "#000000", + "value_variable": "value_variable", + "hint_text": "Select country", + "hint_color": "#888888", + "line_height": 22, + "visibility": "@{visibility}", + "options": [ + { + "value": "" + }, + { + "value": "ru", + "text": "Russia" + }, + { + "value": "uk", + "text": "United Kingdom" + }, + { + "value": "kz" + } + ] + }, + { + "type": "text", + "width": { + "type": "match_parent" + }, + "height": { + "type": "wrap_content" + }, + "paddings": { + "left": 18, + "right": 16, + "bottom": 16 + }, + "font_size": 16, + "text_color": "#000000", + "text": "Text: @{value_variable}" + }, + { + "type": "text", + "text": "Timers", + "margins": { + "left": 16, + "top": 8 + }, + "font_size": 16 + }, + { + "type": "container", + "items": [ + { + "type": "button", + "text": "visibility_invisible", + "action": { + "log_id": "visibility_invisible", + "url": "div-action://timer?id=visibility_invisible&action=start" + } + }, + { + "type": "button", + "text": "visibility_gone", + "action": { + "log_id": "visibility_gone", + "url": "div-action://timer?id=visibility_gone&action=start" + } + }, + { + "type": "button", + "text": "width_change", + "action": { + "log_id": "width_change", + "url": "div-action://timer?id=width_change&action=start" + } + }, + { + "type": "button", + "text": "position_change", + "action": { + "log_id": "position_change", + "url": "div-action://timer?id=position_change&action=start" + } + } + ] + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/test_data/unit_test_data/div-select/with_options.json b/test_data/unit_test_data/div-select/with_options.json new file mode 100644 index 000000000..017c59449 --- /dev/null +++ b/test_data/unit_test_data/div-select/with_options.json @@ -0,0 +1,53 @@ +{ + "type": "select", + "width": { + "type": "match_parent" + }, + "height": { + "type": "wrap_content" + }, + "margins": { + "left": 16, + "top": 20, + "right": 16, + "bottom": 16 + }, + "paddings": { + "left": 16, + "top": 10, + "right": 16, + "bottom": 10 + }, + "alpha": 1.0, + "alignment_horizontal": "center", + "alignment_vertical": "center", + "background": [ + { + "type": "solid", + "color": "#0e000000" + } + ], + "border": { + "corner_radius": 8 + }, + "font_size": 16, + "font_weight": "medium", + "text_color": "#000000", + "value_variable": "value_variable", + "hint_text": "Select country", + "hint_color": "#888888", + "line_height": 22, + "options": [ + { + "value": "ru", + "text": "Russia" + }, + { + "value": "uk", + "text": "United Kingdom" + }, + { + "value": "kz" + } + ] +}