Browse Source

Implement double tap gesture, add tests (#5218)

Implement tap gesture for instrumented tests
Add tests for double tap and text field callout

Fixes
https://youtrack.jetbrains.com/issue/CMP-7483/Support-double-tap-in-Instrumented-tests
pull/5222/head v1.8.0+dev2047
Andrei Salavei 11 months ago committed by GitHub
parent
commit
ef905d8110
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 55
      instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/interaction/BasicInteractionTest.kt
  2. 37
      instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/AccessibilityTestNode.kt
  3. 18
      instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/DpRect+Utils.kt
  4. 39
      instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UIKitInstrumentedTest.kt
  5. 8
      instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UITouch+Utils.kt
  6. 40
      instrumented-test/ui-instrumented-test/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj

55
instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/interaction/BasicInteractionTest.kt

@ -7,11 +7,15 @@ package androidx.compose.test.interaction @@ -7,11 +7,15 @@ package androidx.compose.test.interaction
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.test.utils.assertVisibleInContainer
import androidx.compose.test.utils.findNodeWithLabel
import androidx.compose.test.utils.findNodeWithTag
import androidx.compose.test.utils.runUIKitInstrumentedTest
import androidx.compose.test.utils.toDpRect
import androidx.compose.ui.Alignment
@ -23,6 +27,7 @@ import androidx.compose.ui.platform.testTag @@ -23,6 +27,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BasicInteractionTest {
/**
@ -96,4 +101,52 @@ class BasicInteractionTest { @@ -96,4 +101,52 @@ class BasicInteractionTest {
assertEquals(100 * density.density, state.value.toFloat())
assertEquals(DpRect(DpOffset.Zero, DpSize(screenSize.width, 100.dp)), boxRect)
}
}
@Test
fun testDoubleTap() = runUIKitInstrumentedTest {
var doubleClicked = false
setContentWithAccessibilityEnabled {
Column(modifier = Modifier.safeDrawingPadding()) {
Box(modifier = Modifier.size(100.dp).testTag("Clickable").combinedClickable(
onDoubleClick = { doubleClicked = true }
) {})
TextField("Hello Long Text", {}, modifier = Modifier.testTag("TextField"))
}
}
findNodeWithTag("Clickable").doubleTap()
assertTrue(doubleClicked)
}
@Test
fun testTextFieldCallout() = runUIKitInstrumentedTest {
setContentWithAccessibilityEnabled {
Column(modifier = Modifier.safeDrawingPadding()) {
TextField("Hello-long-long-long-long-long-text", {}, modifier = Modifier.testTag("TextField"))
}
}
findNodeWithTag("TextField").doubleTap()
waitForIdle()
// Verify elements from context menu present
findNodeWithLabel("Cut").let {
it.assertVisibleInContainer()
assertTrue(it.isAccessibilityElement ?: false)
}
findNodeWithLabel("Copy").let {
it.assertVisibleInContainer()
assertTrue(it.isAccessibilityElement ?: false)
}
findNodeWithLabel("Paste").let {
it.assertVisibleInContainer()
assertTrue(it.isAccessibilityElement ?: false)
}
findNodeWithLabel("Select All").let {
it.assertVisibleInContainer()
assertTrue(it.isAccessibilityElement ?: false)
}
}
}

37
instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/AccessibilityTestNode.kt

@ -5,8 +5,7 @@ @@ -5,8 +5,7 @@
package androidx.compose.test.utils
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import kotlinx.cinterop.CValue
import kotlin.test.assertEquals
import kotlin.test.fail
@ -16,6 +15,7 @@ import platform.CoreGraphics.CGRect @@ -16,6 +15,7 @@ import platform.CoreGraphics.CGRect
import platform.UIKit.*
import platform.darwin.NSIntegerMax
import platform.darwin.NSObject
import kotlin.test.assertTrue
/**
* Constructs an accessibility tree representation of the UI hierarchy starting from the window.
@ -40,16 +40,8 @@ internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode @@ -40,16 +40,8 @@ internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode
} else {
val count = element.accessibilityElementCount()
if (count == NSIntegerMax) {
when (element) {
is UITableView -> {
println("warning: UITableView is currently unsupported")
}
is UICollectionView -> {
println("warning: UICollectionView is currently unsupported")
}
is UIView -> {
when {
element is UIView -> {
element.subviews.mapNotNull {
children.add(buildNode(it as UIView, level = level + 1))
}
@ -78,7 +70,9 @@ internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode @@ -78,7 +70,9 @@ internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode
element.accessibilityTraits and it != 0.toULong()
},
element = element
)
).also { node ->
children.forEach { it.parent = node }
}
}
return buildNode(appDelegate.window!!, 0)
@ -120,7 +114,8 @@ internal data class AccessibilityTestNode( @@ -120,7 +114,8 @@ internal data class AccessibilityTestNode(
var frame: DpRect? = null,
var children: List<AccessibilityTestNode>? = null,
var traits: List<UIAccessibilityTraits>? = null,
var element: NSObject? = null
var element: NSObject? = null,
var parent: AccessibilityTestNode? = null,
) {
fun node(builder: AccessibilityTestNode.() -> Unit) {
children = (children ?: emptyList()) + AccessibilityTestNode().apply(builder)
@ -227,6 +222,20 @@ internal fun AccessibilityTestNode.normalized(): AccessibilityTestNode? { @@ -227,6 +222,20 @@ internal fun AccessibilityTestNode.normalized(): AccessibilityTestNode? {
}
}
internal fun AccessibilityTestNode.assertVisibleInContainer() {
var frame = this.frame ?: DpRectZero()
var iterator = parent
while (iterator != null) {
frame = frame.intersect(iterator.frame ?: DpRectZero())
iterator = iterator.parent
}
assertTrue(
frame.width >= 1.dp && frame.height >= 1.dp,
"Element with frame ${this.frame} is not visible or has very small size"
)
}
/**
* Asserts that the current accessibility tree matches the expected structure defined in the
* provided lambda. The expected structure is defined by configuring an `AccessibilityTestNode`,

18
instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/DpRect+Utils.kt

@ -6,10 +6,7 @@ @@ -6,10 +6,7 @@
package androidx.compose.test.utils
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
@ -36,6 +33,19 @@ internal fun Rect.toDpRect(density: Density): DpRect = DpRect( @@ -36,6 +33,19 @@ internal fun Rect.toDpRect(density: Density): DpRect = DpRect(
bottom = bottom.dp / density.density
)
internal fun DpRectZero() = DpRect(0.dp, 0.dp, 0.dp, 0.dp)
internal fun DpRect.intersect(other: DpRect): DpRect {
if (right < other.left || other.right < left) return DpRectZero()
if (bottom < other.top || other.bottom < top) return DpRectZero()
return DpRect(
left = max(left, other.left),
top = max(top, other.top),
right = min(right, other.right),
bottom = min(bottom, other.bottom)
)
}
@OptIn(ExperimentalForeignApi::class)
internal fun CValue<CGPoint>.toDpOffset(): DpOffset = useContents { DpOffset(x.dp, y.dp) }

39
instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UIKitInstrumentedTest.kt

@ -15,7 +15,6 @@ import kotlinx.cinterop.* @@ -15,7 +15,6 @@ import kotlinx.cinterop.*
import platform.Foundation.*
import platform.UIKit.*
import platform.darwin.NSObject
import platform.objc.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -146,44 +145,49 @@ internal class UIKitInstrumentedTest { @@ -146,44 +145,49 @@ internal class UIKitInstrumentedTest {
*
* @param position The position on the root hosting controller.
*/
fun tap(position: DpOffset) {
touchDown(position).up()
fun tap(position: DpOffset): UITouch {
return touchDown(position).up()
}
/**
* Simulates a tap gesture for a given AccessibilityTestNode.
*
* @param position The position on the root hosting controller.
*/
fun AccessibilityTestNode.tap() {
fun AccessibilityTestNode.tap(): UITouch {
val frame = frame ?: error("Internal error. Frame is missing.")
return tap(frame.center())
}
fun AccessibilityTestNode.doubleTap(): UITouch {
val frame = frame ?: error("Internal error. Frame is missing.")
tap(frame.center())
delay(50)
return tap(frame.center())
}
/**
* Simulates a drag gesture on the screen, moving the touch from its current location to a specified position
* over a given duration.
*
* @param position The target position of the drag in DpOffset.
* @param location The target position of the drag in DpOffset.
* @param duration The duration of the drag gesture, defaulting to 0.5 seconds.
* @return The same UITouch instance after completing the drag gesture.
*/
fun UITouch.dragTo(position: DpOffset, duration: Duration = 0.5.seconds): UITouch {
val startPosition = locationInView(appDelegate.window()!!).toDpOffset()
val endPosition = hostingViewController.view.convertPoint(
point = position.toCGPoint(),
fun UITouch.dragTo(location: DpOffset, duration: Duration = 0.5.seconds): UITouch {
val startLocation = locationInView(appDelegate.window()!!).toDpOffset()
val endLocation = hostingViewController.view.convertPoint(
point = location.toCGPoint(),
toView = appDelegate.window()
).toDpOffset()
val startTime = TimeSource.Monotonic.markNow()
while (TimeSource.Monotonic.markNow() <= startTime + duration) {
val progress = ((TimeSource.Monotonic.markNow() - startTime) / duration).coerceIn(0.0, 1.0)
val touchPosition = lerp(startPosition, endPosition, progress.toFloat())
val touchLocation = lerp(startLocation, endLocation, progress.toFloat())
this.moveToPositionOnWindow(touchPosition)
this.moveToLocationOnWindow(touchLocation)
NSRunLoop.currentRunLoop().runUntilDate(NSDate.dateWithTimeIntervalSinceNow(1.0 / 60))
}
this.moveToPositionOnWindow(endPosition)
this.moveToLocationOnWindow(endLocation)
return this
}
@ -196,8 +200,7 @@ internal class UIKitInstrumentedTest { @@ -196,8 +200,7 @@ internal class UIKitInstrumentedTest {
* @return The same UITouch instance after completing the drag gesture.
*/
fun UITouch.dragBy(offset: DpOffset, duration: Duration = 0.5.seconds): UITouch {
val position = locationInView(hostingViewController.view).toDpOffset() + offset
return dragTo(position, duration)
return dragTo(location + offset, duration)
}
/**
@ -212,6 +215,10 @@ internal class UIKitInstrumentedTest { @@ -212,6 +215,10 @@ internal class UIKitInstrumentedTest {
fun UITouch.dragBy(dx: Dp = 0.dp, dy: Dp = 0.dp, duration: Duration = 0.5.seconds): UITouch {
return dragBy(DpOffset(dx, dy), duration)
}
val UITouch.location: DpOffset get() {
return locationInView(hostingViewController.view).toDpOffset()
}
}
@OptIn(ExperimentalForeignApi::class)

8
instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UITouch+Utils.kt

@ -12,9 +12,9 @@ import platform.UIKit.UITouchPhase @@ -12,9 +12,9 @@ import platform.UIKit.UITouchPhase
import platform.UIKit.UIWindow
@OptIn(ExperimentalForeignApi::class)
internal fun UIWindow.touchDown(position: DpOffset): UITouch {
internal fun UIWindow.touchDown(location: DpOffset): UITouch {
return UITouch.touchAtPoint(
point = position.toCGPoint(),
point = location.toCGPoint(),
inWindow = this,
tapCount = 1L,
fromEdge = false
@ -24,8 +24,8 @@ internal fun UIWindow.touchDown(position: DpOffset): UITouch { @@ -24,8 +24,8 @@ internal fun UIWindow.touchDown(position: DpOffset): UITouch {
}
@OptIn(ExperimentalForeignApi::class)
internal fun UITouch.moveToPositionOnWindow(position: DpOffset) {
setLocationInWindow(position.toCGPoint())
internal fun UITouch.moveToLocationOnWindow(location: DpOffset) {
setLocationInWindow(location.toCGPoint())
setPhase(UITouchPhase.UITouchPhaseMoved)
send()
}

40
instrumented-test/ui-instrumented-test/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj

@ -3,9 +3,14 @@ @@ -3,9 +3,14 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 63;
objects = {
/* Begin PBXBuildFile section */
999869392D479FAB0096554D /* HIDEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 999869362D479FAB0096554D /* HIDEvent.m */; };
9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */ = {isa = PBXBuildFile; fileRef = 999869382D479FAB0096554D /* UITouch+Test.m */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
995A49382D3023CC0091FB9B /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
@ -20,16 +25,12 @@ @@ -20,16 +25,12 @@
/* Begin PBXFileReference section */
995A493A2D3023CC0091FB9B /* libCMPTestUtils.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCMPTestUtils.a; sourceTree = BUILT_PRODUCTS_DIR; };
999869352D479FAB0096554D /* HIDEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HIDEvent.h; sourceTree = "<group>"; };
999869362D479FAB0096554D /* HIDEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HIDEvent.m; sourceTree = "<group>"; };
999869372D479FAB0096554D /* UITouch+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UITouch+Test.h"; sourceTree = "<group>"; };
999869382D479FAB0096554D /* UITouch+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITouch+Test.m"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
9928B8252D330E8A006277AD /* CMPTestUtils */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CMPTestUtils;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
995A49372D3023CC0091FB9B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@ -44,10 +45,11 @@ @@ -44,10 +45,11 @@
995A49032D301B510091FB9B = {
isa = PBXGroup;
children = (
9928B8252D330E8A006277AD /* CMPTestUtils */,
9998693B2D479FC80096554D /* CMPTestUtils */,
995A490E2D301B510091FB9B /* Products */,
);
sourceTree = "<group>";
wrapsLines = 1;
};
995A490E2D301B510091FB9B /* Products */ = {
isa = PBXGroup;
@ -57,6 +59,17 @@ @@ -57,6 +59,17 @@
name = Products;
sourceTree = "<group>";
};
9998693B2D479FC80096554D /* CMPTestUtils */ = {
isa = PBXGroup;
children = (
999869352D479FAB0096554D /* HIDEvent.h */,
999869362D479FAB0096554D /* HIDEvent.m */,
999869372D479FAB0096554D /* UITouch+Test.h */,
999869382D479FAB0096554D /* UITouch+Test.m */,
);
path = CMPTestUtils;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -72,9 +85,6 @@ @@ -72,9 +85,6 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
9928B8252D330E8A006277AD /* CMPTestUtils */,
);
name = CMPTestUtils;
packageProductDependencies = (
);
@ -97,6 +107,7 @@ @@ -97,6 +107,7 @@
};
};
buildConfigurationList = 995A49072D301B510091FB9B /* Build configuration list for PBXProject "CMPTestUtils" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@ -105,7 +116,6 @@ @@ -105,7 +116,6 @@
);
mainGroup = 995A49032D301B510091FB9B;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 995A490E2D301B510091FB9B /* Products */;
projectDirPath = "";
projectRoot = "";
@ -120,6 +130,8 @@ @@ -120,6 +130,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
999869392D479FAB0096554D /* HIDEvent.m in Sources */,
9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

Loading…
Cancel
Save