Skip to content

Commit

Permalink
Merge pull request #4475 from wix/feat/long-press-point
Browse files Browse the repository at this point in the history
feat: add `duration` and `point` (optional) parameters for `longPress()` action.
  • Loading branch information
asafkorem authored May 12, 2024
2 parents 4c1c05e + afb5603 commit 620ca86
Show file tree
Hide file tree
Showing 31 changed files with 587 additions and 149 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import com.wix.detox.action.common.MOTION_DIR_DOWN
import com.wix.detox.action.common.MOTION_DIR_LEFT
import com.wix.detox.action.common.MOTION_DIR_RIGHT
import com.wix.detox.action.common.MOTION_DIR_UP
import com.wix.detox.espresso.action.DetoxMultiTap
import com.wix.detox.espresso.action.DetoxCustomTapper
import com.wix.detox.espresso.scroll.DetoxScrollAction

public object DetoxViewActions {
public fun tap() = multiTap(1)
public fun doubleTap() = multiTap(2)
public fun multiTap(times: Int): ViewAction =
actionWithAssertions(GeneralClickAction(DetoxMultiTap(times), GeneralLocation.CENTER, Press.FINGER, 0, 0))
actionWithAssertions(GeneralClickAction(DetoxCustomTapper(times), GeneralLocation.CENTER, Press.FINGER, 0, 0))

public fun scrollUpBy(amountInDp: Double, startOffsetPercentX: Float? = null, startOffsetPercentY: Float? = null): ViewAction =
actionWithAssertions(DetoxScrollAction(MOTION_DIR_UP, amountInDp, startOffsetPercentX, startOffsetPercentY))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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.DetoxCustomTapper;
import com.wix.detox.espresso.action.GetAttributesAction;
import com.wix.detox.espresso.action.LongPressAndDragAction;
import com.wix.detox.espresso.action.RNClickAction;
Expand All @@ -29,6 +29,7 @@
import com.wix.detox.espresso.action.ScrollToIndexAction;
import com.wix.detox.espresso.action.TakeViewScreenshotAction;
import com.wix.detox.espresso.action.common.utils.ViewInteractionExt;
import com.wix.detox.espresso.action.common.DetoxViewConfigurations;
import com.wix.detox.espresso.scroll.DetoxScrollAction;
import com.wix.detox.espresso.scroll.DetoxScrollActionStaleAtEdge;
import com.wix.detox.espresso.scroll.ScrollEdgeException;
Expand All @@ -42,7 +43,6 @@
import java.util.Calendar;
import java.util.Date;


/**
* Created by simonracz on 10/07/2017.
*/
Expand All @@ -57,24 +57,29 @@ private DetoxAction() {
}

public static ViewAction multiClick(int times) {
return actionWithAssertions(new GeneralClickAction(new DetoxMultiTap(times), GeneralLocation.CENTER, Press.FINGER, 0, 0));
return actionWithAssertions(new GeneralClickAction(new DetoxCustomTapper(times), GeneralLocation.CENTER, Press.FINGER, 0, 0));
}

public static ViewAction tapAtLocation(final int x, final int y) {
CoordinatesProvider coordinatesProvider = createCoordinatesProvider(x, y);
return actionWithAssertions(new RNClickAction(coordinatesProvider));
}

private static CoordinatesProvider createCoordinatesProvider(final int x, final int y) {
final int px = DeviceDisplay.convertDpiToPx(x);
final int py = DeviceDisplay.convertDpiToPx(y);
CoordinatesProvider c = new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
final int[] xy = new int[2];
view.getLocationOnScreen(xy);
final float fx = xy[0] + px;
final float fy = xy[1] + py;
return new float[]{fx, fy};
}
};
return actionWithAssertions(new RNClickAction(c));
}

return new CoordinatesProvider() {
@Override
public float[] calculateCoordinates(View view) {
final int[] xy = new int[2];
view.getLocationOnScreen(xy);
final float fx = xy[0] + px;
final float fy = xy[1] + py;
return new float[]{fx, fy};
}
};
};

/**
* Scrolls to the edge of the given scrollable view.
Expand Down Expand Up @@ -208,6 +213,25 @@ public static ViewAction longPressAndDrag(Integer duration,
));
}

public static ViewAction longPress() {
return longPress(null, null, null);
}

public static ViewAction longPress(Integer duration) {
return longPress(null, null, duration);
}

public static ViewAction longPress(Integer x, Integer y) {
return longPress(x, y, null);
}

public static ViewAction longPress(Integer x, Integer y, Integer duration) {
Long finalDuration = duration != null ? duration : DetoxViewConfigurations.getLongPressTimeout();
CoordinatesProvider coordinatesProvider = x == null || y == null ? null : createCoordinatesProvider(x, y);

return actionWithAssertions(new RNClickAction(coordinatesProvider, finalDuration));
}

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 @@ -21,21 +21,23 @@ public class RNClickAction implements ViewAction {
private final GeneralClickAction clickAction;

public RNClickAction() {
clickAction = new GeneralClickAction(
new DetoxSingleTap(),
GeneralLocation.VISIBLE_CENTER,
Press.FINGER,
InputDevice.SOURCE_UNKNOWN,
MotionEvent.BUTTON_PRIMARY);
this(null, null);
}

public RNClickAction(CoordinatesProvider coordinatesProvider) {
this(coordinatesProvider, null);
}

public RNClickAction(CoordinatesProvider coordinatesProvider, Long duration) {
coordinatesProvider = coordinatesProvider != null ? coordinatesProvider : GeneralLocation.VISIBLE_CENTER;

clickAction = new GeneralClickAction(
new DetoxSingleTap(),
coordinatesProvider,
Press.FINGER,
InputDevice.SOURCE_UNKNOWN,
MotionEvent.BUTTON_PRIMARY);
new DetoxSingleTap(duration),
coordinatesProvider,
Press.FINGER,
InputDevice.SOURCE_UNKNOWN,
MotionEvent.BUTTON_PRIMARY
);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ import com.wix.detox.espresso.action.common.TapEvents
*
* This should be Espresso's default implementation IMO.
*/
open class DetoxMultiTap
open class DetoxCustomTapper
@JvmOverloads constructor(
private val times: Int,
private val interTapsDelayMs: Long = getDoubleTapMinTime(),
private val coolDownTimeMs: Long = getPostTapCoolDownTime(),
private val longTapMinTimeMs: Long = getLongTapMinTime(),
private val tapEvents: TapEvents = TapEvents(),
private val uiControllerCallSpy: UiControllerSpy = UiControllerSpy.instance,
private val log: DetoxLog = DetoxLog.instance)
: Tapper {
private val log: DetoxLog = DetoxLog.instance,
private val duration: Long? = null
) : Tapper {

override fun sendTap(uiController: UiController?, coordinates: FloatArray?, precision: FloatArray?)
= sendTap(uiController, coordinates, precision, 0, 0)
Expand Down Expand Up @@ -69,11 +70,12 @@ open class DetoxMultiTap
var downTimestamp: Long? = null

for (i in 1..times) {
val tapEvents = tapEvents.createEventsSeq(coordinates, precision, downTimestamp)
val tapEvents = tapEvents.createEventsSeq(coordinates, precision, downTimestamp, duration)
eventSequence.addAll(tapEvents)

downTimestamp = tapEvents.last().eventTime + interTapsDelayMs
}

return eventSequence
}

Expand Down Expand Up @@ -104,8 +106,8 @@ open class DetoxMultiTap

private fun verifyTapEventTimes(upEvent: CallInfo, downEvent: CallInfo) {
val delta: Long = (upEvent - downEvent)!!
if (delta >= longTapMinTimeMs) {
if (delta >= longTapMinTimeMs && duration == null) {
log.warn(LOG_TAG, "Tap handled too slowly, and turned into a long-tap!") // TODO conditionally turn into an error, based on a global strict-mode detox config
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package com.wix.detox.espresso.action

open class DetoxSingleTap : DetoxMultiTap(1)
class DetoxSingleTap(duration: Long? = null) : DetoxCustomTapper(1, duration = duration)
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ private const val LOG_TAG = "Detox-ViewConfig"

object DetoxViewConfigurations {

/**
* Duration before a press turns into a long press.
* Due to `Tap.LONG`, factor 1.5 is needed, otherwise a long press is not safely detected.
*
* @see androidx.test.espresso.action.Tap.LONG
* @see android.test.TouchUtils.longClickView
*/
@JvmStatic
fun getLongPressTimeout(): Long = (ViewConfiguration.getLongPressTimeout() * 1.5).toLong()

fun getPostTapCoolDownTime() = ViewConfiguration.getDoubleTapTimeout().toLong()

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,24 @@ import android.view.MotionEvent
* Lastly, With the case of _that_ specific bug, we implicitly indirectly work around it with this approach, because we highly increase
* the chance of allowing a frame to be drawn in between the _down_ and _up_ events.
*/
private const val EVENTS_TIME_GAP_MS = 30
private const val EVENTS_TIME_GAP_MS = 30L

class TapEvents(private val motionEvents: MotionEvents = MotionEvents()) {
fun createEventsSeq(coordinates: FloatArray, precision: FloatArray)
= createEventsSeq(coordinates, precision, null)
= createEventsSeq(coordinates, precision, null, null)

fun createEventsSeq(coordinates: FloatArray, precision: FloatArray, downTimestamp: Long?): List<MotionEvent> {
fun createEventsSeq(
coordinates: FloatArray,
precision: FloatArray,
downTimestamp: Long?,
duration: Long?
): List<MotionEvent> {
val (x, y) = coordinates
val downEvent = motionEvents.obtainDownEvent(x, y, precision, downTimestamp)
val upEvent = motionEvents.obtainUpEvent(downEvent, downEvent.eventTime + EVENTS_TIME_GAP_MS, x, y)

val upEventDuration = duration ?: EVENTS_TIME_GAP_MS
val upEvent = motionEvents.obtainUpEvent(downEvent, downEvent.eventTime + upEventDuration, x, y)

return arrayListOf(downEvent, upEvent)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,28 @@ object TapEventsSpec: Spek({
val precision = fancyPrecision()
val downTimestamp = 10203040L

uut().createEventsSeq(coordinates, precision, downTimestamp)
uut().createEventsSeq(coordinates, precision, downTimestamp, null)

verifyDownEventObtainedWithDownTimestamp(coordinates, precision, downTimestamp)
}

it("should allow for down-time to be null") {
it("should allow for duration to be set") {
val duration = 1000L
val expectedUpEventTime = DEFAULT_EVENT_TIME + duration
val coordinates = dontCareCoordinates()
val precision = dontCarePrecision()

uut().createEventsSeq(coordinates, precision, downTimestamp = null as Long?)
uut().createEventsSeq(coordinates, precision, null, duration)

verifyDownEventObtainedWithDownTimestamp(coordinates, precision, null)
verifyUpEventObtainedWithTimestamp(expectedUpEventTime)
}

it("should allow for down-time and duration to be null") {
val coordinates = dontCareCoordinates()
val precision = dontCarePrecision()

uut().createEventsSeq(coordinates, precision, null, null)

verifyDownEventObtainedWithDownTimestamp(coordinates, precision, null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import org.spekframework.spek2.style.specification.describe
import java.lang.NullPointerException
import kotlin.test.assertFailsWith

object DetoxMultiTapSpec: Spek({
describe("Detox multi-tapper replacement for Espresso") {
object DetoxCustomTapperSpec: Spek({
describe("Detox custom-tapper replacement for Espresso") {

val coolDownTimeMs = 111L
val interTapsDelayMs = 667L
Expand All @@ -41,8 +41,10 @@ object DetoxMultiTapSpec: Spek({
mock1stTapEventsSeq = arrayListOf(downEvent, upEvent)
mock2ndTapEventsSeq = arrayListOf(mock(name = "mockSeq2Event1"), mock(name = "mockSeq2Event2"))
tapEvents = mock {
on { createEventsSeq(any(), any(), isNull()) }.doReturn(mock1stTapEventsSeq)
on { createEventsSeq(any(), any(), any()) }.doReturn(mock2ndTapEventsSeq)
on { createEventsSeq(any(), any(), isNull(), isNull()) }.doReturn(mock1stTapEventsSeq)
on { createEventsSeq(any(), any(), isNull(), any()) }.doReturn(mock1stTapEventsSeq)
on { createEventsSeq(any(), any(), any(), isNull()) }.doReturn(mock2ndTapEventsSeq)
on { createEventsSeq(any(), any(), any(), any()) }.doReturn(mock2ndTapEventsSeq)
}

uiControllerCallSpy = mock() {
Expand All @@ -52,9 +54,10 @@ object DetoxMultiTapSpec: Spek({
log = mock()
}

fun verify1stTapEventsSeqGenerated() = verify(tapEvents).createEventsSeq(coordinates, precision, null)
fun verify2ndTapEventsSeqGenerated() = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), any())
fun verify2ndTapEventsGenerateWithTimestamp(downTimestamp: Long) = verify(tapEvents).createEventsSeq(any(), any(), eq(downTimestamp))
fun verify1stTapEventsSeqGenerated(duration: Long? = null) = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), isNull(), eq(duration))
fun verify2ndTapEventsSeqGenerated() = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), isNull(), isNull())
fun verify2ndTapEventsGenerateWithTimestamp(downTimestamp: Long) = verify(tapEvents).createEventsSeq(eq(coordinates), eq(precision), eq(downTimestamp), isNull())

fun verifyAllTapEventsInjected() = verify(uiController).injectMotionEventSequence(arrayListOf(mock1stTapEventsSeq, mock2ndTapEventsSeq).flatten())
fun verifyMainThreadSynced() = verify(uiController).loopMainThreadForAtLeast(eq(coolDownTimeMs))
fun verifyMainThreadNeverSynced() = verify(uiController, never()).loopMainThreadForAtLeast(any())
Expand All @@ -64,17 +67,24 @@ object DetoxMultiTapSpec: Spek({
fun givenInjectionError() = whenever(uiController.injectMotionEventSequence(any())).doThrow(RuntimeException("exceptionMock"))

fun givenInjectionCallsHistory(injectionsHistory: List<CallInfo?>) =
whenever(uiControllerCallSpy.eventInjectionsIterator()).thenReturn(injectionsHistory.iterator())
whenever(uiControllerCallSpy.eventInjectionsIterator()).thenReturn(injectionsHistory.iterator())

fun uut(times: Int, duration: Long? = null) =
DetoxCustomTapper(times, interTapsDelayMs, coolDownTimeMs, longTapMinTimeMs, tapEvents, uiControllerCallSpy, log, duration)

fun uut(times: Int) = DetoxMultiTap(times, interTapsDelayMs, coolDownTimeMs, longTapMinTimeMs, tapEvents, uiControllerCallSpy, log)
fun sendOneTap(uut: DetoxMultiTap = uut(1)) = uut.sendTap(uiController, coordinates, precision, -1, -1)
fun sendTwoTaps(uut: DetoxMultiTap = uut(2)) = uut.sendTap(uiController, coordinates, precision, -1, -1)
fun sendOneTap(duration: Long? = null) = uut(1, duration).sendTap(uiController, coordinates, precision, -1, -1)
fun sendTwoTaps(uut: DetoxCustomTapper = uut(2)) = uut.sendTap(uiController, coordinates, precision, -1, -1)

it("should generate a single-tap events sequence using tap-events helper") {
sendOneTap()
verify1stTapEventsSeqGenerated()
}

it("should generate a single-tap events sequence with a custom duration") {
sendOneTap(1000L)
verify1stTapEventsSeqGenerated(1000L)
}

it("should generate multiple sets of single-tap event sequences using tap-events helper") {
sendTwoTaps()
verify1stTapEventsSeqGenerated()
Expand Down
17 changes: 13 additions & 4 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,18 +1306,27 @@ declare global {
interface NativeElementActions extends NativeElementWaitableActions{
/**
* Simulate tap on an element
* @param point relative coordinates to the matched element (the element size could changes on different devices or even when changing the device font size)
* @param point coordinates in the element's coordinate space. Optional (default is the center of the element).
* @example await element(by.id('tappable')).tap();
* @example await element(by.id('tappable')).tap({ x:5, y:10 });
*/
tap(point?: Point2D): Promise<void>;

/**
* Simulate long press on an element
* @param duration (iOS only) custom press duration time, in milliseconds. Optional (default is 1000ms).
* @param point coordinates in the element's coordinate space. Optional (default is the center of the element).
* @param duration custom press duration time, in milliseconds. Optional (defaults to the standard long-press duration for the platform).
* Custom durations should be used cautiously, as they can affect test consistency and user experience expectations.
* They are typically necessary when testing components that behave differently from the platform's defaults or when simulating unique user interactions.
* @example await element(by.id('tappable')).longPress();
* @example await element(by.id('tappable')).longPress(2000);
* @example await element(by.id('tappable')).longPress({ x:5, y:10 });
* @example await element(by.id('tappable')).longPress({ x:5, y:10 }, 1500);
*/
longPress(duration?: number): Promise<void>;
longPress(): Promise<void>;
longPress(point: Point2D): Promise<void>;
longPress(duration: number): Promise<void>;
longPress(point: Point2D, duration: number): Promise<void>;

/**
* Simulate long press on an element and then drag it to the position of the target element. (iOS Only)
Expand All @@ -1335,7 +1344,7 @@ declare global {

/**
* Simulate tap at a specific point on an element.
* Note: The point coordinates are relative to the matched element and the element size could changes on different devices or even when changing the device font size.
* Note: The point coordinates are relative to the matched element and the element size could change on different devices or even when changing the device font size.
* @example await element(by.id('tappable')).tapAtPoint({ x:5, y:10 });
* @deprecated Use `.tap()` instead.
*/
Expand Down
Loading

0 comments on commit 620ca86

Please sign in to comment.