diff --git a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/interaction/BasicInteractionTest.kt b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/interaction/BasicInteractionTest.kt index adeaa77b82..f5dc386241 100644 --- a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/interaction/BasicInteractionTest.kt +++ b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/interaction/BasicInteractionTest.kt @@ -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 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 { 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) + } + } +} \ No newline at end of file diff --git a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/AccessibilityTestNode.kt b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/AccessibilityTestNode.kt index 8f77d16626..4fce65acee 100644 --- a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/AccessibilityTestNode.kt +++ b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/AccessibilityTestNode.kt @@ -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 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 } 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 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( var frame: DpRect? = null, var children: List? = null, var traits: List? = 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? { } } +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`, diff --git a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/DpRect+Utils.kt b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/DpRect+Utils.kt index c5db33267f..f82e5c0cb8 100644 --- a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/DpRect+Utils.kt +++ b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/DpRect+Utils.kt @@ -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( 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.toDpOffset(): DpOffset = useContents { DpOffset(x.dp, y.dp) } diff --git a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UIKitInstrumentedTest.kt b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UIKitInstrumentedTest.kt index 2aa3a7203f..de0058f4e7 100644 --- a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UIKitInstrumentedTest.kt +++ b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UIKitInstrumentedTest.kt @@ -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 { * * @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 { * @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 { 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) diff --git a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UITouch+Utils.kt b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UITouch+Utils.kt index a7a0d8dccc..c70a14440f 100644 --- a/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UITouch+Utils.kt +++ b/instrumented-test/ui-instrumented-test/src/iosMain/kotlin/androidx/compose/test/utils/UITouch+Utils.kt @@ -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 { } @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() } diff --git a/instrumented-test/ui-instrumented-test/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj b/instrumented-test/ui-instrumented-test/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj index 47b6c6f282..c3d8fcdd63 100644 --- a/instrumented-test/ui-instrumented-test/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj +++ b/instrumented-test/ui-instrumented-test/src/iosMain/objc/CMPTestUtils.xcodeproj/project.pbxproj @@ -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 @@ /* 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 = ""; }; + 999869362D479FAB0096554D /* HIDEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HIDEvent.m; sourceTree = ""; }; + 999869372D479FAB0096554D /* UITouch+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UITouch+Test.h"; sourceTree = ""; }; + 999869382D479FAB0096554D /* UITouch+Test.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITouch+Test.m"; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 9928B8252D330E8A006277AD /* CMPTestUtils */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = CMPTestUtils; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - /* Begin PBXFrameworksBuildPhase section */ 995A49372D3023CC0091FB9B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -44,10 +45,11 @@ 995A49032D301B510091FB9B = { isa = PBXGroup; children = ( - 9928B8252D330E8A006277AD /* CMPTestUtils */, + 9998693B2D479FC80096554D /* CMPTestUtils */, 995A490E2D301B510091FB9B /* Products */, ); sourceTree = ""; + wrapsLines = 1; }; 995A490E2D301B510091FB9B /* Products */ = { isa = PBXGroup; @@ -57,6 +59,17 @@ name = Products; sourceTree = ""; }; + 9998693B2D479FC80096554D /* CMPTestUtils */ = { + isa = PBXGroup; + children = ( + 999869352D479FAB0096554D /* HIDEvent.h */, + 999869362D479FAB0096554D /* HIDEvent.m */, + 999869372D479FAB0096554D /* UITouch+Test.h */, + 999869382D479FAB0096554D /* UITouch+Test.m */, + ); + path = CMPTestUtils; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -72,9 +85,6 @@ ); dependencies = ( ); - fileSystemSynchronizedGroups = ( - 9928B8252D330E8A006277AD /* CMPTestUtils */, - ); name = CMPTestUtils; packageProductDependencies = ( ); @@ -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 @@ ); mainGroup = 995A49032D301B510091FB9B; minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; productRefGroup = 995A490E2D301B510091FB9B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -120,6 +130,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 999869392D479FAB0096554D /* HIDEvent.m in Sources */, + 9998693A2D479FAB0096554D /* UITouch+Test.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };