Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adjustSliderToPosition on Android #2888

Merged
merged 9 commits into from
Jul 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import android.view.View;

import com.facebook.react.views.slider.ReactSliderManager;
import com.wix.detox.common.DetoxErrors.DetoxRuntimeException;
import com.wix.detox.common.DetoxErrors.StaleActionException;
import com.wix.detox.espresso.action.AdjustSliderToPositionAction;
import com.wix.detox.espresso.action.DetoxMultiTap;
import com.wix.detox.espresso.action.RNClickAction;
import com.wix.detox.espresso.action.ScreenshotResult;
Expand Down Expand Up @@ -148,6 +150,11 @@ public static ViewAction scrollToIndex(int index) {
return new ScrollToIndexAction(index);
}

public static ViewAction adjustSliderToPosition(final double newPosition) {
ReactSliderManager reactSliderManager = new ReactSliderManager();
return new AdjustSliderToPositionAction(newPosition, reactSliderManager);
}

public static ViewAction takeViewScreenshot() {
return new ViewActionWithResult<String>() {
private final TakeViewScreenshotAction action = new TakeViewScreenshotAction();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import static androidx.test.espresso.matcher.ViewMatchers.isFocused;
import static com.wix.detox.espresso.matcher.ViewMatchers.isMatchingAtIndex;
import static com.wix.detox.espresso.matcher.ViewMatchers.isOfClassName;
import static com.wix.detox.espresso.matcher.ViewMatchers.toHaveSliderPosition;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.is;
Expand Down Expand Up @@ -106,4 +107,7 @@ public static Matcher<View> matcherForFocus() {
return isFocused();
}

public static Matcher<View> matcherForSliderPosition(double position, double tolerance) {
return toHaveSliderPosition(position, tolerance);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
package com.wix.detox.espresso.matcher

import android.view.View
import androidx.test.espresso.matcher.BoundedMatcher
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import com.facebook.react.views.slider.ReactSlider
import org.hamcrest.BaseMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
Expand All @@ -23,15 +25,16 @@ fun isOfClassName(className: String): Matcher<View> {
// empty
}

return object: BaseMatcher<View>() {
return object : BaseMatcher<View>() {
override fun matches(item: Any) = false
override fun describeTo(description: Description) {
description.appendText("Class $className not found on classpath. Are you using full class name?")
}
}
}

fun isMatchingAtIndex(index: Int, innerMatcher: Matcher<View>): Matcher<View> = ViewAtIndexMatcher(index, innerMatcher)
fun isMatchingAtIndex(index: Int, innerMatcher: Matcher<View>): Matcher<View> =
ViewAtIndexMatcher(index, innerMatcher)

/**
* Same as [androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom], but accepts any class. Needed
Expand All @@ -44,3 +47,25 @@ private class IsAssignableFromMatcher(private val clazz: Class<*>) : TypeSafeMat
description.appendText("is assignable from class: $clazz")
}
}

fun toHaveSliderPosition(expectedValue: Double, tolerance: Double): Matcher<View?> {
return object : BoundedMatcher<View?, ReactSlider>(ReactSlider::class.java) {
override fun describeTo(description: Description) {
description.appendText("expected: $expectedValue")
}

override fun matchesSafely(slider: ReactSlider?): Boolean {
val currentProgress = slider?.progress

if (currentProgress != null) {
val realProgress = slider.toRealProgress(currentProgress)
val currentPctFactor = slider.max / currentProgress.toDouble()
val realTotal = realProgress * currentPctFactor
val actualValue = realProgress / realTotal
return Math.abs(actualValue - expectedValue) <= tolerance
}

return false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.wix.detox.espresso.action

import android.view.View
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.views.slider.ReactSlider
import com.facebook.react.views.slider.ReactSliderManager
import org.hamcrest.Matcher
import org.hamcrest.Matchers

class AdjustSliderToPositionAction(private val desiredPosition: Double, private val mManager: ReactSliderManager) : ViewAction {
override fun getConstraints(): Matcher<View?>? = Matchers.allOf(
ViewMatchers.isAssignableFrom(ReactSlider::class.java),
getIsDisplayed())

override fun getDescription() = "adjustSliderToPosition"

fun getIsDisplayed(): Matcher<View?> = ViewMatchers.isDisplayed()

private fun buildStyles(vararg keysAndValues: Any) = ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues))

private fun calculateProgressTarget(view: ReactSlider): Double {
val sliderProgress = view.toRealProgress(view.progress)
val sliderScrollFactor = view.max / view.progress.toDouble()
val sliderMaxValue = sliderProgress * sliderScrollFactor
return desiredPosition * sliderMaxValue
}

override fun perform(uiController: UiController?, view: View) {
val progressNewValue = calculateProgressTarget(view as ReactSlider)
mManager.updateProperties(view, buildStyles("value", progressNewValue))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.wix.detox.espresso.action

import android.view.View
import com.facebook.react.views.slider.ReactSlider
import com.facebook.react.views.slider.ReactSliderManager
import com.nhaarman.mockitokotlin2.*
import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.Matcher
import org.junit.Before
import org.junit.Test

@Suppress("IllegalIdentifier")
class AdjustSliderToPositionActionTest {
val mockReactSliderManager: ReactSliderManager = mock()
var uut: AdjustSliderToPositionAction = spy(AdjustSliderToPositionAction(0.25, mockReactSliderManager))
private lateinit var mockReactSlider: ReactSlider

@Before
fun setup() {
mockReactSlider = mock {
on {progress}.thenReturn(250)
}
}

@Test
fun `should have correct description`() {
assertThat(uut.description).isEqualTo("adjustSliderToPosition")
}

@Test
fun `should have correct constraints`() {
val mockReactSlider: ReactSlider = mock()
val mockView: View = mock()
val mockIsDisplayed: Matcher<View?> = mock {
on {matches(any())}.thenReturn(true)
}
doReturn(mockIsDisplayed).whenever(uut).getIsDisplayed()

assertThat(uut.constraints).isNotNull
assertThat(uut.constraints!!.matches(null)).isFalse()
assertThat(uut.constraints!!.matches(1)).isFalse()
assertThat(uut.constraints!!.matches(mockReactSlider)).isTrue()
assertThat(uut.constraints!!.matches(mockView)).isFalse()
}

@Test
fun `should change progress of slider`() {
val mockReactSliderManager: ReactSliderManager = mock {
on{updateProperties(any(), any())}.thenAnswer{
doReturn(750).whenever(mockReactSlider).progress
}
}
jonathanmos marked this conversation as resolved.
Show resolved Hide resolved
uut = spy(AdjustSliderToPositionAction(0.75, mockReactSliderManager))
uut.perform(null, mockReactSlider)

verify(mockReactSliderManager, times(1)).updateProperties(any(), any())
assertThat(mockReactSlider.progress).isEqualTo(750)
}
}
14 changes: 14 additions & 0 deletions detox/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,14 @@ declare global {
* @example await expect(element(by.id('UniqueId533'))).toHaveValue('0');
*/
toHaveValue(value: any): R;

/**
* Expect Slider to have a position (0 - 1).
* Can have an optional tolerance to take into account rounding issues on ios
* @example await expect(element(by.id('SliderId'))).toHavePosition(0.75);
* @example await expect(element(by.id('SliderId'))).toHavePosition(0.74, 0.1);
*/
toHaveSliderPosition(position: number, tolerance?: number): Promise<void>;
}

interface WaitForFacade {
Expand Down Expand Up @@ -1047,6 +1055,12 @@ declare global {
*/
scrollTo(edge: Direction): Promise<void>;

/**
* Adjust slider to position.
* @example await element(by.id('slider')).adjustSliderToPosition(0.75);
*/
adjustSliderToPosition(newPosition: number): Promise<void>;
jonathanmos marked this conversation as resolved.
Show resolved Hide resolved

/**
* Swipes in the provided direction at the provided speed, started from percentage.
* @param speed default: `fast`
Expand Down
11 changes: 11 additions & 0 deletions detox/src/android/AndroidExpect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ describe('AndroidExpect', () => {
await expectToThrow(() => e.waitFor('notAnElement').toBeVisible());
});

it('toHaveSliderPosition', async () => {
await e.expect(e.element(e.by.id('sliderWithASimpleID'))).toHaveSliderPosition(0.25);
await e.expect(e.element(e.by.id('sliderWithASimpleID'))).toHaveSliderPosition(0.25, 0.1);
await e.expect(e.element(e.by.id('sliderWithASimpleID'))).not.toHaveSliderPosition(0.25);
await e.expect(e.element(e.by.id('sliderWithASimpleID'))).not.toHaveSliderPosition(0.25, 0.1);
});

describe('element interactions', () => {
it('should tap and long-press', async () => {
await e.element(e.by.label('Tap Me')).tap();
Expand Down Expand Up @@ -239,6 +246,10 @@ describe('AndroidExpect', () => {
const result = await e.element(e.by.id('UniqueId005')).getAttributes();
expect(result).toEqual(execResult);
});

it('should adjust slider to position', async () => {
await e.element(e.by.id('sliderWithASimpleID')).adjustSliderToPosition(75);
});
});

describe('element screenshots', () => {
Expand Down
8 changes: 8 additions & 0 deletions detox/src/android/actions/native.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ class ScrollToIndex extends Action {
}
}

class AdjustSliderToPosition extends Action {
constructor(newPosition) {
super();
this._call = invoke.callDirectly(DetoxActionApi.adjustSliderToPosition(newPosition));
}
}

class TakeElementScreenshot extends Action {
constructor() {
super();
Expand All @@ -148,4 +155,5 @@ module.exports = {
SwipeAction,
TakeElementScreenshot,
ScrollToIndex,
AdjustSliderToPosition,
};
4 changes: 4 additions & 0 deletions detox/src/android/core/NativeElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ class NativeElement {
const result = await new ActionInteraction(this._invocationManager, this, new actions.GetAttributes()).execute();
return JSON.parse(result);
}

async adjustSliderToPosition(newPosition) {
return await new ActionInteraction(this._invocationManager, this, new actions.AdjustSliderToPosition(newPosition)).execute();
}
}

module.exports = {
Expand Down
4 changes: 4 additions & 0 deletions detox/src/android/core/NativeExpect.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ class NativeExpectElement extends NativeExpect {
return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.ToggleMatcher(value).not : new matchers.ToggleMatcher(value)).execute();
}

async toHaveSliderPosition(value, tolerance = 0) {
return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.SliderPositionMatcher(value, tolerance).not : new matchers.SliderPositionMatcher(value, tolerance)).execute();
}

async toBeFocused() {
return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.FocusMatcher().not : new matchers.FocusMatcher()).execute();
}
Expand Down
15 changes: 15 additions & 0 deletions detox/src/android/espressoapi/DetoxAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ class DetoxAction {
};
}

static adjustSliderToPosition(newPosition) {
if (typeof newPosition !== "number") throw new Error("newPosition should be a number, but got " + (newPosition + (" (" + (typeof newPosition + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.DetoxAction"
},
method: "adjustSliderToPosition",
args: [{
type: "Double",
value: newPosition
}]
};
}

static takeViewScreenshot() {
return {
target: {
Expand Down
19 changes: 19 additions & 0 deletions detox/src/android/espressoapi/DetoxMatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,25 @@ class DetoxMatcher {
};
}

static matcherForSliderPosition(position, tolerance) {
if (typeof position !== "number") throw new Error("position should be a number, but got " + (position + (" (" + (typeof position + ")"))));
if (typeof tolerance !== "number") throw new Error("tolerance should be a number, but got " + (tolerance + (" (" + (typeof tolerance + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.espresso.DetoxMatcher"
},
method: "matcherForSliderPosition",
args: [{
type: "Double",
value: position
}, {
type: "Double",
value: tolerance
}]
};
}

}

module.exports = DetoxMatcher;
8 changes: 8 additions & 0 deletions detox/src/android/matchers/native.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ class FocusMatcher extends NativeMatcher {
}
}

class SliderPositionMatcher extends NativeMatcher {
constructor(value, tolerance) {
super();
this._call = invoke.callDirectly(DetoxMatcherApi.matcherForSliderPosition(value, tolerance));
}
}

module.exports = {
LabelMatcher,
IdMatcher,
Expand All @@ -88,4 +95,5 @@ module.exports = {
ValueMatcher,
ToggleMatcher,
FocusMatcher,
SliderPositionMatcher,
};
16 changes: 11 additions & 5 deletions detox/test/e2e/03.actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,16 @@ describe('Actions', () => {
await expect(element(by.id('UniqueId007'))).toBeVisible();
});

it(':ios: should adjust slider and assert its value', async () => {
await expect(element(by.id('sliderWithASimpleID'))).toHaveSliderPosition(0.25);
await element(by.id('sliderWithASimpleID')).adjustSliderToPosition(0.75);
await expect(element(by.id('sliderWithASimpleID'))).toHaveSliderPosition(0.75);
await expect(element(by.id('sliderWithASimpleID'))).toHaveValue("75%");
it('should adjust slider and assert its value', async () => {
const reactSliderId = 'sliderWithASimpleID';
await expect(element(by.id(reactSliderId))).toHaveSliderPosition(0.25);
await element(by.id(reactSliderId)).adjustSliderToPosition(0.75);
await expect(element(by.id(reactSliderId))).not.toHaveSliderPosition(0.74);
await expect(element(by.id(reactSliderId))).toHaveSliderPosition(0.74, 0.1);

// on ios the accessibilityLabel is set to the slider value, but not on android
if (device.getPlatform() === 'ios') {
jonathanmos marked this conversation as resolved.
Show resolved Hide resolved
await expect(element(by.id(reactSliderId))).toHaveValue('75%');
}
});
});
2 changes: 1 addition & 1 deletion detox/test/src/Screens/ActionsScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export default class ActionsScreen extends Component {
</View>

<View style={{ height: 40, borderColor: '#c0c0c0', marginHorizontal: 20 }}>
<Slider testID='sliderWithASimpleID' maximumValue={1000.0} minimumValue={0.0} value={250.0} />
<Slider testID='sliderWithASimpleID' maximumValue={1000.0} minimumValue={0.0} value={250.0}/>
</View>

<View>
Expand Down