diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 23abc5a14..309083bfb 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -35,5 +35,10 @@ gradlePlugin { id = "artifact-deploy" implementationClass = "deploy.ArtifactPublisher" } + + create("grantTestPermissions") { + id = "grant-test-permissions" + implementationClass = "GrantTestPermissions" + } } } diff --git a/buildSrc/src/main/kotlin/GrantTestPermissions.kt b/buildSrc/src/main/kotlin/GrantTestPermissions.kt new file mode 100644 index 000000000..5f10d912d --- /dev/null +++ b/buildSrc/src/main/kotlin/GrantTestPermissions.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.kotlin.dsl.create +import java.io.File + +/** + * An extension to configure the [GrantTestPermissions] plugin. + */ +abstract class PermissionPluginExtension { + + /** + * The package name of the application for which permissions will be granted. + */ + abstract val packageName: Property + + /** + * The path to the ADB executable. + */ + abstract val adbPath: Property + + /** + * A list of permissions to be granted to the application. + * Each permission should be specified as a string, e.g., "android.permission.CAMERA". + */ + abstract val permissions: ListProperty +} + +/** + * A Gradle plugin to grant runtime permissions to a test application. This plugin is useful for + * Android instrumented tests that require specific permissions to be granted before running tests. + * It automates the process of granting permissions using ADB. + * + * @since 300.0.0 + */ +class GrantTestPermissions : Plugin { + override fun apply(project: Project) { + val extension = + project.extensions.create("grantTestPermissionsConfig") + val grantPermissionTask = project.tasks.register("grantTestPermissions") { + doLast { + val packageName = extension.packageName.get() + val adb = extension.adbPath.get() + val permissions = extension.permissions.get() + println("Granting permissions for package: $packageName") + permissions.forEach { permission -> + if (permission.contains("MANAGE_EXTERNAL_STORAGE")) { + // Special handling for MANAGE_EXTERNAL_STORAGE permission + project.providers.exec { + commandLine( + adb, + "shell", + "appops", + "set", + "--uid", + packageName, + "MANAGE_EXTERNAL_STORAGE", + "allow" + ) + }.result.get().assertNormalExitValue() + } else { + // General permission granting + project.providers.exec { + commandLine(adb, "shell", "pm", "grant", packageName, permission) + }.result.get().assertNormalExitValue() + } + println("Granted permission: $permission") + } + } + } + + project.afterEvaluate { + // Configure the task runs right after the install task + project.tasks.matching { it.name.startsWith("install") && it.name.endsWith("AndroidTest") } + .forEach { installTask -> + grantPermissionTask.get().dependsOn(installTask) + } + // Configure the task runs before the connectedAndroidTest tasks + project.tasks.matching { it.name.startsWith("connected") && it.name.endsWith("AndroidTest") } + .forEach { connectedTask -> + connectedTask.dependsOn(grantPermissionTask.get()) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2d082cdb..ccbf30865 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,8 +14,6 @@ androidxTestExt = "1.3.0" androidXTestRunner = "1.7.0" androidXTestRules = "1.7.0" androidxWindow = "1.4.0" -mockingjay = "2.0.0" -workVersion = "2.10.3" binaryCompatibilityValidator = "0.18.1" compileSdk = "36" compose-navigation = "2.9.3" @@ -29,6 +27,7 @@ ksp = "2.2.10-2.0.2" media3Exoplayer = "1.8.0" minSdk = "28" mlkitBarcodeScanning = "17.3.0" +mockingjay = "2.0.0" kotlinxCoroutinesTest = "1.10.2" kotlinxSerializationJson = "1.9.0" mockkAndroid = "1.14.5" @@ -39,6 +38,7 @@ arcore = "1.50.0" playServicesLocation = "21.3.0" gmazzo = "2.4.6" gradleSecrets = "2.0.1" +workVersion = "2.10.3" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose"} diff --git a/toolkit/featureforms/build.gradle.kts b/toolkit/featureforms/build.gradle.kts index bfe5cbd1d..d96f06f8d 100644 --- a/toolkit/featureforms/build.gradle.kts +++ b/toolkit/featureforms/build.gradle.kts @@ -23,6 +23,7 @@ plugins { id("artifact-deploy") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") id("kotlin-parcelize") + id("grant-test-permissions") alias(libs.plugins.binary.compatibility.validator) apply true alias(libs.plugins.kotlin.serialization) apply true } @@ -51,14 +52,14 @@ android { disable += "MissingTranslation" disable += "MissingQuantity" } - + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() - + testApplicationId = "com.arcgismaps.toolkit.featureforms.test" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } - + buildTypes { release { isMinifyEnabled = false @@ -111,6 +112,19 @@ android { } } +grantTestPermissionsConfig { + packageName.set(android.defaultConfig.testApplicationId) + adbPath.set(android.adbExecutable.absoluteFile) + permissions.set( + listOf( + "android.permission.CAMERA", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.MANAGE_EXTERNAL_STORAGE" + ) + ) +} + apiValidation { ignoredClasses.add("com.arcgismaps.toolkit.featureforms.BuildConfig") // todo: remove when this is resolved https://github.com/Kotlin/binary-compatibility-validator/issues/74 @@ -153,7 +167,6 @@ apiValidation { "com.arcgismaps.toolkit.featureforms.internal.screens.ComposableSingletons\$SelectNetworkSourceScreenKt", "com.arcgismaps.toolkit.featureforms.internal.utils.ComposableSingletons\$SearchBarKt" ) - ignoredClasses.addAll(composableSingletons) } diff --git a/toolkit/featureforms/src/androidTest/AndroidManifest.xml b/toolkit/featureforms/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..6474d6c46 --- /dev/null +++ b/toolkit/featureforms/src/androidTest/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/BarcodeTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/BarcodeTests.kt index d8150c5a3..72b65a19d 100644 --- a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/BarcodeTests.kt +++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/BarcodeTests.kt @@ -16,7 +16,6 @@ package com.arcgismaps.toolkit.featureforms -import android.Manifest import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule @@ -25,7 +24,6 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.requestFocus -import androidx.test.rule.GrantPermissionRule import com.arcgismaps.mapping.featureforms.BarcodeScannerFormInput import com.arcgismaps.mapping.featureforms.FieldFormElement import com.google.common.truth.Truth.assertThat @@ -41,11 +39,6 @@ class BarcodeTests : FeatureFormTestRunner( @get:Rule val composeTestRule = createComposeRule() - // Grant camera permission for barcode scanning - @get:Rule - val runtimePermissionRule: GrantPermissionRule = - GrantPermissionRule.grant(Manifest.permission.CAMERA) - /** * Test case 11.1: * Given a `FeatureForm` with a `BarcodeScannerFormInput` diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FeatureFormTestRunner.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FeatureFormTestRunner.kt index 3058e29bb..414285932 100644 --- a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FeatureFormTestRunner.kt +++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/FeatureFormTestRunner.kt @@ -21,8 +21,6 @@ import com.arcgismaps.LoadStatus import com.arcgismaps.Loadable import com.arcgismaps.data.ArcGISFeature import com.arcgismaps.data.QueryParameters -import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeResponse -import com.arcgismaps.httpcore.authentication.NetworkAuthenticationChallenge import com.arcgismaps.httpcore.authentication.NetworkAuthenticationChallengeHandler import com.arcgismaps.httpcore.authentication.NetworkAuthenticationChallengeResponse import com.arcgismaps.httpcore.authentication.ServerTrust @@ -54,8 +52,9 @@ open class FeatureFormTestRunner( private val objectId: Long, private val user: String = BuildConfig.webMapUser, private val password: String = BuildConfig.webMapPassword, - private val layerName: String = "" + private val layerName: String = "", ) { + /** * The feature form for the feature with the given [objectId]. */ diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/GroupElementTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/GroupElementTests.kt index 368cc0ec4..f04ea6f5c 100644 --- a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/GroupElementTests.kt +++ b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/GroupElementTests.kt @@ -70,13 +70,15 @@ class GroupElementTests : FeatureFormTestRunner( groupElement1.assertIsDisplayed() // assert description is displayed groupElement1.assertTextContains(groupFormElement1.description) - assert(groupElement1.isToggled()) + // assert that the group is expanded + assert(groupElement1.onParent().isToggled()) // assert this group has children including the header assert(groupElement1.onParent().onChildren().fetchSemanticsNodes().count() > 1) val groupElement2 = composeTestRule.onNodeWithText("Group with Multiple Form Elements 2") groupElement2.assertIsDisplayed() - assert(!groupElement2.isToggled()) + // assert the group is expanded + assert(!groupElement2.onParent().isToggled()) // assert that only the header is displayed assert(groupElement2.onParent().onChildren().fetchSemanticsNodes().count() == 1) } diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/UtilityAssociationsFormElementTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/UtilityAssociationsFormElementTests.kt deleted file mode 100644 index 9611dd9f8..000000000 --- a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/UtilityAssociationsFormElementTests.kt +++ /dev/null @@ -1,506 +0,0 @@ -/* - * Copyright 2025 Esri - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.arcgismaps.toolkit.featureforms - -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasScrollAction -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isDialog -import androidx.compose.ui.test.isDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onChildren -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeLeft -import androidx.test.espresso.Espresso -import com.arcgismaps.ArcGISEnvironment -import com.arcgismaps.data.ArcGISFeature -import com.arcgismaps.data.QueryParameters -import com.arcgismaps.mapping.ArcGISMap -import com.arcgismaps.mapping.featureforms.FeatureForm -import com.arcgismaps.mapping.featureforms.UtilityAssociationsFormElement -import com.arcgismaps.mapping.layers.FeatureLayer -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -class UtilityAssociationsFormElementTests { - - private lateinit var map: ArcGISMap - - @get:Rule - val composeTestRule = createComposeRule() - - private val scope = CoroutineScope(Dispatchers.Main) - - private val comboBoxDialogListSemanticLabel = "ComboBoxDialogLazyColumn" - private val comboBoxDialogDoneButtonSemanticLabel = "combo box dialog close button" - - init { - // Set the authentication challenge handler - ArcGISEnvironment.authenticationManager.arcGISAuthenticationChallengeHandler = - FeatureFormsTestChallengeHandler( - username = BuildConfig.traceToolUser, - password = BuildConfig.traceToolPassword, - ) - } - - @Before - fun setup() = runTest { - if (::map.isInitialized.not()) { - map = ArcGISMap( - uri = "https://www.arcgis.com/home/item.html?id=a93ff75c66644c02bdb3a785cc0ba795" - ) - } - map.assertIsLoaded() - map.utilityNetworks.forEach { - it.assertIsLoaded() - } - } - - /** - * Test case 12.1 - * - * Given a `FeatureForm` with a `UtilityAssociationsFormElement` - * When the `FeatureForm` is displayed - * Then the associations are displayed with the correct terminal information - * - * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-121-associations-show-terminal - */ - @Test - fun testTerminalIsDisplayed() = runTest { - val groupLayer = map.operationalLayers.first() - val layer = groupLayer.subLayerContents.value.find { - it.name == "Electric Distribution Device" - } as FeatureLayer? - assertThat(layer).isNotNull() - - val queryResult = layer!!.featureTable!!.queryFeatures( - QueryParameters().apply { - objectIds.add(5050) - } - ).getOrNull() - assertThat(queryResult).isNotNull() - - val feature = queryResult!!.firstOrNull() as? ArcGISFeature - assertThat(feature).isNotNull() - - val featureForm = FeatureForm(feature!!) - val featureFormState = FeatureFormState( - featureForm = featureForm, - coroutineScope = scope - ) - composeTestRule.setContent { - FeatureForm(featureFormState = featureFormState) - } - - val element = featureForm.elements.first { - it is UtilityAssociationsFormElement - } as UtilityAssociationsFormElement - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 20_000) { - element.associationsFilterResults.isNotEmpty() - } - - val lazyColumnNode = composeTestRule.onNodeWithContentDescription("lazy column") - lazyColumnNode.performScrollToNode(hasText("Associations")).assertIsDisplayed() - - // Verify the association filters are displayed - val connectedNode = composeTestRule.onNodeWithText("Connected").assertIsDisplayed() - composeTestRule.onNodeWithText("Structure").assertIsDisplayed() - composeTestRule.onNodeWithText("Container").assertIsDisplayed() - - // Click on the connected filter - connectedNode.performClick() - - // Verify the groups are displayed - var listView = composeTestRule.onNode(hasScrollAction()) - listView.onChildWithText("Electric Distribution Junction").assertIsDisplayed() - val groupNode = listView.onChildWithText("Electric Distribution Device").assertIsDisplayed() - groupNode.performClick() - - // Verify the associations are displayed - listView = composeTestRule.onNode(hasScrollAction()) - - // Verify 2 items are displayed with the title "Fuse" - val items = listView.onChildren().filter(hasText("Fuse")) - items.assertCountEquals(2) - // Verify the terminals are displayed - items[0].assert(hasText("Single Terminal")) - items[1].assert(hasText("Single Terminal")) - } - - /** - * Test case 12.3 - * - * Given a `FeatureForm` with a `UtilityAssociationsFormElement` - * When the `FeatureForm` is displayed - * Then the containment visibility is displayed for containment associations - * - * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-123-containment-association-shows-containment-visibility - */ - @Test - fun testContainmentVisibilityIsDisplayed() = runTest { - val groupLayer = map.operationalLayers.first() - val layer = groupLayer.subLayerContents.value.find { - it.name == "Structure Boundary" - } as FeatureLayer? - assertThat(layer).isNotNull() - - val queryResult = layer!!.featureTable!!.queryFeatures( - QueryParameters().apply { - objectIds.add(2) - } - ).getOrNull() - assertThat(queryResult).isNotNull() - - val feature = queryResult!!.firstOrNull() as? ArcGISFeature - assertThat(feature).isNotNull() - - val featureForm = FeatureForm(feature!!) - val featureFormState = FeatureFormState( - featureForm = featureForm, - coroutineScope = scope - ) - composeTestRule.setContent { - FeatureForm(featureFormState = featureFormState) - } - - val element = featureForm.elements.first { - it is UtilityAssociationsFormElement - } as UtilityAssociationsFormElement - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 20_000) { - element.associationsFilterResults.isNotEmpty() - } - - val lazyColumnNode = composeTestRule.onNodeWithContentDescription("lazy column") - lazyColumnNode.performScrollToNode(hasText("Associations")).assertIsDisplayed() - - val contentNode = composeTestRule.onNodeWithText("Content") - lazyColumnNode.performScrollToNode(hasText("Content")) - contentNode.performClick() - - // Verify the groups are displayed - var listView = composeTestRule.onNode(hasScrollAction()) - listView.onChildWithText("Electric Distribution Device").assertIsDisplayed().performClick() - - // Verify the associations are displayed - listView = composeTestRule.onNode(hasScrollAction()) - val firstElement = listView.onChildWithText("Circuit Breaker").assertIsDisplayed() - firstElement.assert(hasText("Visible: false")) - } - - /** - * Test case 12.4 - * - * Given a `FeatureForm` with a `UtilityAssociationsFormElement` - * When the `FeatureForm` is displayed - * Then the containment visibility is not displayed for container associations - * - * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-124-containment-association-doesnt-show-containment-visibility - */ - @Test - fun testContainmentVisibilityIsNotDisplayed() = runTest { - val groupLayer = map.operationalLayers.first() - val layer = groupLayer.subLayerContents.value.find { - it.name == "Electric Distribution Device" - } as FeatureLayer? - assertThat(layer).isNotNull() - - val queryResult = layer!!.featureTable!!.queryFeatures( - QueryParameters().apply { - objectIds.add(2584) - } - ).getOrNull() - assertThat(queryResult).isNotNull() - - val feature = queryResult!!.firstOrNull() as? ArcGISFeature - assertThat(feature).isNotNull() - - val featureForm = FeatureForm(feature!!) - val featureFormState = FeatureFormState( - featureForm = featureForm, - coroutineScope = scope - ) - composeTestRule.setContent { - FeatureForm(featureFormState = featureFormState) - } - - val element = featureForm.elements.first { - it is UtilityAssociationsFormElement - } as UtilityAssociationsFormElement - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 20_000) { - element.associationsFilterResults.isNotEmpty() - } - - val lazyColumnNode = composeTestRule.onNodeWithContentDescription("lazy column") - lazyColumnNode.performScrollToNode(hasText("Associations")).assertIsDisplayed() - - val containerNode = composeTestRule.onNodeWithText("Container").assertIsDisplayed() - containerNode.performClick() - - // Verify the groups are displayed - var listView = composeTestRule.onNode(hasScrollAction()) - listView.onChildWithText("Structure Boundary").assertIsDisplayed().performClick() - - // Verify the associations are displayed - listView = composeTestRule.onNode(hasScrollAction()) - - val firstElement = listView.onChildWithText("Substation").assertIsDisplayed() - firstElement.assert(hasText("Content").not()) - } - - /** - * Test case 12.5 - * - * Given a `FeatureForm` with a `UtilityAssociationsFormElement` - * When the `FeatureForm` is displayed - * Then any edits to the `FeatureForm` prevent navigation to associated features - * - * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-125-edits-prevent-navigating - */ - @Test - fun testEditsPreventNavigation() = runTest { - val groupLayer = map.operationalLayers.first() - val layer = groupLayer.subLayerContents.value.find { - it.name == "Electric Distribution Device" - } as FeatureLayer? - assertThat(layer).isNotNull() - - val queryResult = layer!!.featureTable!!.queryFeatures( - QueryParameters().apply { - objectIds.add(3321) - } - ).getOrNull() - assertThat(queryResult).isNotNull() - - val feature = queryResult!!.firstOrNull() as? ArcGISFeature - assertThat(feature).isNotNull() - - val featureForm = FeatureForm(feature!!) - val featureFormState = FeatureFormState( - featureForm = featureForm, - coroutineScope = scope - ) - composeTestRule.setContent { - FeatureForm(featureFormState = featureFormState) - } - - val element = featureForm.elements.first { - it is UtilityAssociationsFormElement - } as UtilityAssociationsFormElement - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 20_000) { - element.associationsFilterResults.isNotEmpty() - } - - val lazyColumnNode = composeTestRule.onNodeWithContentDescription("lazy column") - lazyColumnNode.performScrollToNode(hasText("Associations")).assertIsDisplayed() - - val connectedNode = composeTestRule.onNodeWithText("Connected").assertIsDisplayed() - connectedNode.performClick() - - // Verify the groups are displayed - var listView = composeTestRule.onNode(hasScrollAction()) - listView.onChildWithText("Electric Distribution Device").assertIsDisplayed().performClick() - - listView = composeTestRule.onNode(hasScrollAction()) - val firstElement = listView.onChildWithText("Transformer").assertIsDisplayed() - firstElement.performClick() - - val formElementNode = composeTestRule.onNodeWithText("Asset type *") - formElementNode.assertIsDisplayed() - formElementNode.performClick() - - // find the combo box dialog - val comboBoxDialogList = - composeTestRule.onNodeWithContentDescription(comboBoxDialogListSemanticLabel) - comboBoxDialogList.assertIsDisplayed() - // tap on the "unknown" option - val listItem = - comboBoxDialogList.onChildWithContentDescription("Unknown list item") - listItem.assertIsDisplayed() - listItem.performClick() - // find and tap the done button - val doneButton = - composeTestRule.onNodeWithContentDescription(comboBoxDialogDoneButtonSemanticLabel) - doneButton.performClick() - - // Verify the edit actions are displayed - composeTestRule.onNodeWithText("Save").assertIsDisplayed().assertHasClickAction() - composeTestRule.onNodeWithText("Discard").assertIsDisplayed().assertHasClickAction() - - // Press the back button to trigger the save edits dialog - Espresso.pressBack() - // Verify the save edits dialog is displayed - val dialog = composeTestRule.onNode(isDialog()).assertExists() - // Find and click the "Discard" button - val discardButton = dialog.onChildWithText("Discard", recurse = true).assertIsDisplayed() - discardButton.performClick() - // Verify the initial association is displayed again - firstElement.assertIsDisplayed() - } - - /** - * Test case 12.6 - * - * Given a `FeatureForm` with a `UtilityAssociationsFormElement` - * When the user deletes an associations - * Then the associations are removed from the list of associations - * - * https://devtopia.esri.com/runtime/common-toolkit/blob/main/designs/Forms/FormsTestDesign.md#test-case-126-delete-association - */ - @Test - fun testDeleteAssociations() = runTest { - val groupLayer = map.operationalLayers.first() - val layer = groupLayer.subLayerContents.value.find { - it.name == "Electric Distribution Device" - } as FeatureLayer? - assertThat(layer).isNotNull() - // Query for the feature with object ID 3321 - val queryResult = layer!!.featureTable!!.queryFeatures( - QueryParameters().apply { - objectIds.add(3321) - } - ).getOrNull() - assertThat(queryResult).isNotNull() - // Get the feature from the query result - val feature = queryResult!!.firstOrNull() as? ArcGISFeature - assertThat(feature).isNotNull() - // Create the feature form and state - val featureForm = FeatureForm(feature!!) - val featureFormState = FeatureFormState( - featureForm = featureForm, - coroutineScope = scope - ) - // Set the content of the compose test rule to the feature form - composeTestRule.setContent { - FeatureForm(featureFormState = featureFormState) - } - - // Find the utility associations form element - val element = featureForm.elements.first { - it is UtilityAssociationsFormElement - } as UtilityAssociationsFormElement - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 20_000) { - element.associationsFilterResults.isNotEmpty() - } - - // Find the associations element in the UI - val lazyColumnNode = composeTestRule.onNodeWithContentDescription("lazy column") - lazyColumnNode.performScrollToNode(hasText("Associations")).assertIsDisplayed() - // Click on the connected filter - val connectedNode = composeTestRule.onNodeWithText("Connected").assertIsDisplayed() - connectedNode.performClick() - - // Verify the groups are displayed - var listView = composeTestRule.onNode(hasScrollAction()) - // Click on the "Electric Distribution Device" group - listView.onChildWithText("Electric Distribution Device").assertIsDisplayed().performClick() - // Update the list view reference to the current one - listView = composeTestRule.onNode(hasScrollAction()) - - // Find the association with the title "Transformer" - var transformer = listView.onChildWithText("Transformer").assertIsDisplayed() - val detailsIcon = transformer.onChildWithContentDescription("details") - // Assert the details icon is displayed and has a click action - detailsIcon.assertIsDisplayed().assertHasClickAction() - detailsIcon.performClick() - - // -- This section tests the delete association button -- - // Find the dialog, there should only be one - var dialog = composeTestRule.onNode(isDialog()) - dialog.assertIsDisplayed() - var removeButton = dialog.onChildWithText("Remove Association", recurse = true) - removeButton.assertIsDisplayed().assertHasClickAction() - removeButton.performClick() - - // Find and click the cancel button - val cancelButton = composeTestRule.onNodeWithText("Cancel").assertIsDisplayed().performClick() - // Verify the dialog is dismissed by asserting the cancel button does not exist - cancelButton.assertDoesNotExist() - - // Click the remove button again - removeButton.performClick() - // Find the confirmation dialog - composeTestRule.onNodeWithText("Remove").performClick() - // Verify the dialogs are dismissed - composeTestRule.onNodeWithText("Remove").assertDoesNotExist() - dialog.assertDoesNotExist() - // Verify the association is removed from the list - composeTestRule.onNodeWithText("Transformer").assertDoesNotExist() - // Also verify we are back at the group level since the last association was deleted - composeTestRule.onNodeWithText("Connected").assertIsDisplayed() - - // Verify the save, discard buttons are shown - composeTestRule.onNodeWithText("Save").assertIsDisplayed().assertHasClickAction() - composeTestRule.onNodeWithText("Discard").assertIsDisplayed().performClick() - // Wait until the discard action is complete which is asynchronous - composeTestRule.waitUntil { - composeTestRule.onNode(hasScrollAction()).isDisplayed() - } - // Verify the groups are displayed - listView = composeTestRule.onNode(hasScrollAction()) - // Verify the "Electric Distribution Device" group is displayed - listView.onChildWithText("Electric Distribution Device").isDisplayed() - - // -- This section tests the swipe to delete -- - listView.onChildWithText("Electric Distribution Device").performClick() - // Update the list view reference to the current one - listView = composeTestRule.onNode(hasScrollAction()) - transformer = listView.onChildWithText("Transformer").assertIsDisplayed() - transformer.performTouchInput { - this.swipeLeft() - } - // Find and click the confirmation dialog remove button, there should only be one dialog - dialog = composeTestRule.onNode(isDialog()) - dialog.assertIsDisplayed() - dialog.onChildWithText("Cancel", recurse = true).assertIsDisplayed().performClick() - dialog.assertDoesNotExist() - - // Swipe left again to delete - transformer.performTouchInput { - this.swipeLeft() - } - // Click the remove button this time - dialog.assertIsDisplayed() - dialog.onChildWithText("Remove", recurse = true).assertIsDisplayed().performClick() - // Verify the dialog is dismissed - dialog.assertDoesNotExist() - // Verify the association is removed from the list - composeTestRule.onNodeWithText("Transformer").assertDoesNotExist() - // Also verify we are back at the group level since the last association was deleted - composeTestRule.onNodeWithText("Connected").assertIsDisplayed() - // Verify the save, discard buttons are also shown - composeTestRule.onNodeWithText("Save").assertIsDisplayed().assertHasClickAction() - composeTestRule.onNodeWithText("Discard").assertIsDisplayed().assertHasClickAction() - } -} diff --git a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/UtilityNetworkNavigationTests.kt b/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/UtilityNetworkNavigationTests.kt deleted file mode 100644 index 4545af2f1..000000000 --- a/toolkit/featureforms/src/androidTest/java/com/arcgismaps/toolkit/featureforms/UtilityNetworkNavigationTests.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2025 Esri - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.arcgismaps.toolkit.featureforms - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasTextExactly -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.espresso.Espresso -import com.arcgismaps.mapping.featureforms.UtilityAssociationsFormElement -import com.google.common.truth.Truth.assertThat -import org.junit.Rule -import org.junit.Test - -class UtilityNetworkNavigationTests : FeatureFormTestRunner( - uri = "https://rt-server114.esri.com/portal/home/item.html?id=f997acc3f5894008b583307d55e1ae4e", - objectId = 10000000008, - user = BuildConfig.unTestUser, - password = BuildConfig.unTestPassword, - layerName = "Structure Boundary" -) { - @get:Rule - val composeTestRule = createComposeRule() - - /** - * Given a FeatureForm with a UtilityAssociationsFormElement - * When the navigation is enabled - * Then the user should be able to navigate to a new form if there are no edits made - * And return to the original form - */ - @Test - fun testNavigationWithoutEdits() { - val state = FeatureFormState( - featureForm, - coroutineScope = scope - ) - composeTestRule.setContent { - FeatureForm( - featureFormState = state - ) - } - val element = featureForm.elements.first() as? UtilityAssociationsFormElement - assertThat(element).isNotNull() - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 30_000) { - element!!.associationsFilterResults.isNotEmpty() - } - val filter = element!!.associationsFilterResults.first().filter - val groupResult = element.associationsFilterResults.first().groupResults.first() - // Check that the filter, group, and association results are displayed - val filterNode = composeTestRule.onNodeWithText(text = filter.title) - filterNode.assertIsDisplayed() - filterNode.performClick() - val groupNode = composeTestRule.onNodeWithText(text = groupResult.name) - groupNode.assertIsDisplayed() - groupNode.performClick() - val associationNode = - composeTestRule.onNode(hasTextExactly("MediumVoltage", "Visible: false")) - associationNode.assertIsDisplayed() - // Navigate to a new Form - associationNode.performClick() - // Wait for the new form to load - composeTestRule.waitUntil { - state.activeFeatureForm != featureForm - } - // Check that the State object has the new form - assertThat(state.activeFeatureForm).isNotEqualTo(featureForm) - val newForm = state.activeFeatureForm - val newFormTitleNode = composeTestRule.onNodeWithText(text = newForm.title.value) - newFormTitleNode.assertIsDisplayed() - // Navigate back to the original form - Espresso.pressBack() - // Wait for the original form to load - composeTestRule.waitUntil { - state.activeFeatureForm == featureForm - } - // Check that the State object has the original form - assertThat(state.activeFeatureForm).isEqualTo(featureForm) - // The association node should be displayed again - associationNode.assertIsDisplayed() - } - - /** - * Given a FeatureForm with a UtilityAssociationsFormElement - * When the navigation is disabled - * Then the user should not be able to navigate to a new form - */ - @Test - fun testNavigationIsDisabled() { - val state = FeatureFormState( - featureForm, - coroutineScope = scope - ) - composeTestRule.setContent { - FeatureForm( - featureFormState = state, - isNavigationEnabled = false - ) - } - val element = featureForm.elements.first() as? UtilityAssociationsFormElement - assertThat(element).isNotNull() - // Wait for the associations to load - composeTestRule.waitUntil(timeoutMillis = 30_000) { - element!!.associationsFilterResults.isNotEmpty() - } - val filter = element!!.associationsFilterResults.first().filter - val groupResult = element.associationsFilterResults.first().groupResults.first() - // Check that the filter, group, and association results are displayed - val filterNode = composeTestRule.onNodeWithText(text = filter.title) - filterNode.assertIsDisplayed() - filterNode.performClick() - val groupNode = composeTestRule.onNodeWithText(text = groupResult.name) - groupNode.assertIsDisplayed() - groupNode.performClick() - val associationNode = - composeTestRule.onNode(hasTextExactly("MediumVoltage", "Visible: false")) - associationNode.assertIsDisplayed() - // Navigate to a new Form - associationNode.performClick() - // Check that the navigation is disabled - assertThat(state.activeFeatureForm).isEqualTo(featureForm) - } -} diff --git a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/internal/components/formelement/GroupElement.kt b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/internal/components/formelement/GroupElement.kt index 2b417c521..bac82052e 100644 --- a/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/internal/components/formelement/GroupElement.kt +++ b/toolkit/featureforms/src/main/java/com/arcgismaps/toolkit/featureforms/internal/components/formelement/GroupElement.kt @@ -101,7 +101,9 @@ private fun GroupElement( ) { val colors = LocalColorScheme.current.groupElementColors Card( - modifier = modifier, + modifier = modifier.semantics(mergeDescendants = true) { + toggleableState = if (expanded) ToggleableState.On else ToggleableState.Off + }, shape = GroupElementDefaults.containerShape, colors = CardDefaults.cardColors( containerColor = colors.containerColor @@ -109,11 +111,7 @@ private fun GroupElement( border = BorderStroke(GroupElementDefaults.borderThickness, colors.outlineColor) ) { GroupElementHeader( - modifier = Modifier - .fillMaxWidth() - .semantics { - toggleableState = if (expanded) ToggleableState.On else ToggleableState.Off - }, + modifier = Modifier.fillMaxWidth(), title = label, description = description, isExpanded = expanded,