diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt index 523ed5a60d..428b7ef2c2 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext +import org.groundplatform.android.BuildConfig.USE_EMULATORS import org.groundplatform.android.coroutines.IoDispatcher import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.data.remote.firebase.schema.GroundFirestore @@ -42,7 +43,7 @@ import org.groundplatform.android.model.mutation.SubmissionMutation import org.groundplatform.android.model.toListItem import timber.log.Timber -const val PROFILE_REFRESH_CLOUD_FUNCTION_NAME = "profile-refresh" +private const val PROFILE_REFRESH_CLOUD_FUNCTION_NAME = "profile-refresh" @Singleton class FirestoreDataStore @@ -91,6 +92,7 @@ internal constructor( withContext(ioDispatcher) { db().surveys().survey(survey.id).lois().fetchSharedLois(survey) } override suspend fun subscribeToSurveyUpdates(surveyId: String) { + if (USE_EMULATORS) return Timber.d("Subscribing to FCM topic $surveyId") try { Firebase.messaging.subscribeToTopic(surveyId).await() diff --git a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/PointRenderer.kt b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/PointRenderer.kt index 91c6993d14..10d4ebd01a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/map/gms/features/PointRenderer.kt +++ b/app/src/main/java/org/groundplatform/android/ui/map/gms/features/PointRenderer.kt @@ -24,11 +24,15 @@ import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions import javax.inject.Inject import org.groundplatform.android.R +import org.groundplatform.android.common.Constants.isReleaseBuild import org.groundplatform.android.model.geometry.Point import org.groundplatform.android.ui.IconFactory import org.groundplatform.android.ui.map.Feature import org.groundplatform.android.ui.map.gms.MARKER_Z import org.groundplatform.android.ui.map.gms.toLatLng +import org.jetbrains.annotations.TestOnly + +@TestOnly const val TEST_MARKER_TAG = "Test point" class PointRenderer @Inject @@ -56,6 +60,9 @@ constructor(resources: Resources, private val markerIconFactory: IconFactory) : icon(getMarkerIcon(style, selected)) zIndex(MARKER_Z) visible(visible) + if (!isReleaseBuild()) { + contentDescription(TEST_MARKER_TAG) + } } val marker = map.addMarker(markerOptions) ?: error("Failed to create marker") marker.tag = tag diff --git a/e2eTest/build.gradle b/e2eTest/build.gradle index 0378634a46..c710a8dd37 100644 --- a/e2eTest/build.gradle +++ b/e2eTest/build.gradle @@ -64,6 +64,7 @@ android { } dependencies { + implementation libs.androidx.compose.ui.test.junit4 androidTestUtil libs.androidx.orchestrator implementation libs.androidx.appcompat implementation libs.androidx.core.ktx diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/AutomatorRunner.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/AutomatorRunner.kt deleted file mode 100644 index d5006e467c..0000000000 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/AutomatorRunner.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * 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 - * - * https://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 org.groundplatform.android.e2etest - -import android.content.Context -import android.content.Intent -import android.widget.EditText -import androidx.annotation.StringRes -import androidx.test.core.app.ApplicationProvider -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.By -import androidx.test.uiautomator.BySelector -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until -import kotlin.reflect.KClass -import org.groundplatform.android.e2etest.TestConfig.LONG_TIMEOUT -import org.groundplatform.android.e2etest.TestConfig.SHORT_TIMEOUT - -interface AutomatorRunner { - var device: UiDevice - - fun stringResource( - @StringRes resId: Int, - context: Context = InstrumentationRegistry.getInstrumentation().targetContext, - ): String = context.getString(resId) - - fun byText(@StringRes resId: Int): BySelector = By.text(stringResource(resId)) - - fun byClass(kclass: KClass): BySelector = By.clazz(kclass.java.name) - - fun waitClickGone(selector: BySelector, timeout: Long = SHORT_TIMEOUT): Boolean { - device.wait(Until.hasObject(selector), timeout) - device.findObject(selector)?.click() - return device.wait(Until.gone(selector), timeout) - } - - fun hasTextField() = device.hasObject(byClass(EditText::class)) - - fun enterText(text: String) { - val textSelector = byClass(EditText::class) - device.wait(Until.hasObject(textSelector), SHORT_TIMEOUT) - device.findObject(textSelector).text = text - } - - fun allowPermissions() { - waitClickGone(By.textContains("While using the app")) - } - - fun launchPackage(packageName: String) { - // Start from the home screen - device.pressHome() - - // Wait for launcher - val launcherPackage: String = device.launcherPackageName - device.wait(Until.hasObject(By.pkg(launcherPackage).depth(0)), LONG_TIMEOUT) - - // Launch the app - val context = ApplicationProvider.getApplicationContext() - val intent = - context.packageManager.getLaunchIntentForPackage(packageName)?.apply { - // Clear out any previous instances - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) - } - context.startActivity(intent) - } -} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt deleted file mode 100644 index fc6144f251..0000000000 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/SurveyRunnerTest.kt +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * 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 - * - * https://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 org.groundplatform.android.e2etest - -import android.util.Log -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText -import android.widget.RadioButton -import androidx.test.core.app.takeScreenshot -import androidx.test.core.graphics.writeToTestStorage -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.By -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until -import java.io.IOException -import junit.framework.TestCase.fail -import org.groundplatform.android.R -import org.groundplatform.android.e2etest.TestConfig.GROUND_PACKAGE -import org.groundplatform.android.e2etest.TestConfig.LONG_TIMEOUT -import org.groundplatform.android.e2etest.TestConfig.SHORT_TIMEOUT -import org.groundplatform.android.e2etest.TestConfig.TEST_SURVEY_LOI_TASK_INDEX -import org.groundplatform.android.e2etest.TestConfig.TEST_SURVEY_TASKS_ADHOC -import org.groundplatform.android.model.task.Task -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestName -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class SurveyRunnerTest : AutomatorRunner { - - @get:Rule var nameRule = TestName() - - override lateinit var device: UiDevice - - @Test - fun run() { - launchGround() - signIn() - selectTestSurvey() - zoomIntoLocation() - startAdHocLoiTask() - fillOutTaskData(isAdHoc = true, TEST_SURVEY_TASKS_ADHOC) - clickSubmissionConfirmationDone() - startPredefinedLoiTask() - fillOutTaskData(isAdHoc = false, TEST_SURVEY_TASKS_ADHOC) - clickSubmissionConfirmationDone() - } - - private fun launchGround() { - // Initialize UiDevice instance. - device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - launchPackage(GROUND_PACKAGE) - - // Wait for the app to appear. - if (!device.wait(Until.hasObject(By.pkg(GROUND_PACKAGE).depth(0)), LONG_TIMEOUT)) { - captureScreenshot() - fail("Failed to launch app.") - } - device.wait(Until.hasObject(byText(R.string.initializing)), SHORT_TIMEOUT) - if (device.wait(Until.gone(byText(R.string.initializing)), LONG_TIMEOUT) == null) { - captureScreenshot() - fail("Timed out while initializing.") - } - } - - private fun signIn() { - if (!waitClickGone(byClass(Button::class), LONG_TIMEOUT)) { - captureScreenshot() - fail("Failed to sign in.") - } - } - - private fun selectTestSurvey() { - // Code for the test will be updated in a follow up MR - } - - private fun zoomIntoLocation() { - // Code for the test will be updated in a follow up MR - } - - private fun startAdHocLoiTask() { - // Code for the test will be updated in a follow up MR - } - - private fun startPredefinedLoiTask() { - // Code for the test will be updated in a follow up MR - } - - private fun fillOutTaskData(isAdHoc: Boolean, taskList: List) { - taskList.forEachIndexed { i, it -> - device.waitForIdle() - if (!isAdHoc && i == TEST_SURVEY_LOI_TASK_INDEX) { - return@forEachIndexed - } - when (it) { - Task.Type.DROP_PIN -> completeDropPinTask() - Task.Type.DRAW_AREA -> completeDrawArea() - Task.Type.CAPTURE_LOCATION -> completeCaptureLocation() - Task.Type.MULTIPLE_CHOICE -> completeMultipleChoice() - Task.Type.TEXT -> completeText() - Task.Type.PHOTO -> completePhoto() - Task.Type.NUMBER -> completeNumber() - Task.Type.DATE -> completeDate() - Task.Type.TIME -> completeTime() - Task.Type.INSTRUCTIONS -> Unit - Task.Type.UNKNOWN -> fail("Should not get here") - } - if (i < taskList.size - 1) { - clickNext() - if (isAdHoc && i == TEST_SURVEY_LOI_TASK_INDEX) { - setLoiName() - } - } else { - clickDone() - } - } - } - - private fun completeDropPinTask() { - // Instructions dialog may be triggered. - waitClickGone(byText(R.string.close)) - waitClickGone(byText(R.string.drop_pin)) - } - - private fun completeDrawArea() { - // Instructions dialog may be triggered. - waitClickGone(byText(R.string.close)) - waitClickGone(byText(R.string.add_point)) - waitClickGone(byText(R.string.add_point)) - waitClickGone(byText(R.string.add_point)) - waitClickGone(byText(R.string.complete_polygon)) - } - - private fun completeCaptureLocation() { - waitClickGone(byText(R.string.capture)) - } - - private fun completeMultipleChoice() { - val radioSelector = byClass(RadioButton::class) - val checkBoxSelector = byClass(CheckBox::class) - val optionSelector = if (device.hasObject(radioSelector)) radioSelector else checkBoxSelector - val options = device.findObjects(optionSelector) - // Ensure that the first option is selected last. - options.reversed().forEach { it.click() } - if (hasTextField()) { - enterText("An other option") - } - } - - private fun completeText() { - enterText("A text answer") - } - - private fun completePhoto() { - allowPermissions() - waitClickGone(byText(R.string.camera)) - waitClickGone(By.res("com.android.camera2:id/shutter_button")) - waitClickGone(By.res("com.android.camera2:id/done_button")) - } - - private fun completeNumber() { - enterText("1234") - } - - private fun completeDate() { - device.findObject(byClass(EditText::class)).click() - waitClickGone(byText(R.string.ok)) - } - - private fun completeTime() { - device.findObject(byClass(EditText::class)).click() - waitClickGone(byText(R.string.ok)) - } - - private fun clickNext() { - waitClickGone(byText(R.string.next)) - } - - private fun clickDone() { - waitClickGone(byText(R.string.done)) - } - - private fun clickSubmissionConfirmationDone() { - waitClickGone(byText(R.string.done), LONG_TIMEOUT) - } - - private fun clickLocationLock() { - waitClickGone(By.res("org.groundplatform.android", "location_lock_btn"), timeout = LONG_TIMEOUT) - } - - private fun setLoiName() { - captureScreenshot() - if (device.wait(Until.hasObject(byText(R.string.save)), SHORT_TIMEOUT) == null) { - captureScreenshot() - fail("Failed to find loi name popup") - } - enterText("An loi name") - waitClickGone(byText(R.string.save)) - } - - private fun captureScreenshot() { - val screenShotName = "${javaClass.simpleName}_${nameRule.methodName}" - Log.d("Screenshots", "Taking screenshot of '$screenShotName'") - try { - takeScreenshot().writeToTestStorage(screenShotName) - } catch (ex: IOException) { - Log.e("Screenshots", "Could not take the screenshot", ex) - } - } -} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index f5b236b69a..28f5c9648c 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -20,20 +20,22 @@ import org.groundplatform.android.model.task.Task object TestConfig { const val LONG_TIMEOUT = 30000L const val SHORT_TIMEOUT = 10000L - const val GROUND_PACKAGE = "org.groundplatform.android" - val TEST_SURVEY_TASKS_ADHOC = + const val SURVEY_NAME = "Ground app E2E test" + const val TEST_JOB_ALL_TASK_TYPES_EXCEPT_DRAW_AREA = "Test all task types except draw area" + val TEST_LIST_ALL_TASK_TYPES_EXCEPT_DRAW_AREA = listOf( - Task.Type.CAPTURE_LOCATION, - Task.Type.DROP_PIN, - Task.Type.TEXT, - Task.Type.MULTIPLE_CHOICE, - Task.Type.MULTIPLE_CHOICE, - Task.Type.NUMBER, - Task.Type.DATE, - Task.Type.TIME, - Task.Type.PHOTO, - Task.Type.CAPTURE_LOCATION, + TestTask(taskType = Task.Type.DROP_PIN, isRequired = true), + TestTask(Task.Type.INSTRUCTIONS), + TestTask(Task.Type.TEXT), + TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = listOf(1)), + TestTask(taskType = Task.Type.MULTIPLE_CHOICE, selectIndexes = (0..3).toList()), + TestTask(Task.Type.NUMBER), + TestTask(Task.Type.PHOTO), + TestTask(Task.Type.DATE), + TestTask(Task.Type.TIME), + TestTask(taskType = Task.Type.CAPTURE_LOCATION, isRequired = true), ) - val TEST_SURVEY_LOI_TASK_INDEX = 1 - const val TEST_SURVEY_IDENTIFIER = "test" + const val TEST_JOB_DRAW_AREA = "Test draw area" + val TEST_LIST_DRAW_AREA = listOf(TestTask(taskType = Task.Type.DRAW_AREA, isRequired = true)) + const val LOI_NAME = "Test location" } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt new file mode 100644 index 0000000000..ffc3db15d4 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestTask.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest + +import org.groundplatform.android.model.task.Task + +data class TestTask( + val taskType: Task.Type, + val isRequired: Boolean = false, + val selectIndexes: List? = null, +) diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt new file mode 100644 index 0000000000..36c7969293 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.drivers + +import android.graphics.Point +import android.widget.DatePicker +import android.widget.TimePicker +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import org.groundplatform.android.R +import org.groundplatform.android.e2etest.TestConfig.SHORT_TIMEOUT +import org.groundplatform.android.e2etest.extensions.onTarget + +@OptIn(ExperimentalTestApi::class) +class AndroidTestDriver( + private val composeRule: AndroidComposeTestRule<*, *>, + private val device: UiDevice, +) : TestDriver { + private fun wait(target: TestDriver.Target, timeout: Long = SHORT_TIMEOUT) { + when (target) { + is TestDriver.Target.ContentDescription -> + composeRule.waitUntilAtLeastOneExists(hasContentDescription(target.text), timeout) + is TestDriver.Target.TestTag -> + composeRule.waitUntilAtLeastOneExists(hasTestTag(target.tag), timeout) + is TestDriver.Target.Text -> + composeRule.waitUntilAtLeastOneExists(hasText(target.text, target.substring), timeout) + is TestDriver.Target.ViewId -> { + val resName = composeRule.activity.resources.getResourceEntryName(target.resId) + val packageName = composeRule.activity.packageName + device.wait(Until.findObject(By.res(packageName, resName)), timeout) + } + } + } + + override fun click(target: TestDriver.Target) { + wait(target) + if (target is TestDriver.Target.ViewId) { + onView(withId(target.resId)).perform(ViewActions.click()) + } else { + composeRule.onTarget(target).performClick() + } + } + + override fun selectFromList(target: TestDriver.Target, index: Int) { + wait(target) + if (target is TestDriver.Target.ViewId) { + val resName = composeRule.activity.resources.getResourceEntryName(target.resId) + val packageName = composeRule.activity.packageName + val parent = device.findObject(By.res(packageName, resName)) + parent.children[index].click() + } else { + composeRule.onTarget(target, index).performClick() + } + } + + override fun dragMapBy(offsetX: Int, offsetY: Int) { + wait(TestDriver.Target.ViewId(R.id.map)) + val resName = composeRule.activity.resources.getResourceEntryName(R.id.map) + val packageName = composeRule.activity.packageName + val map = device.findObject(By.res(packageName, resName)) + val center = map.visibleCenter + + map.drag(Point(center.x + offsetX, center.y + offsetY)) + } + + override fun clickMapMarker(description: String) { + wait(TestDriver.Target.ViewId(R.id.map)) + val marker: UiObject = device.findObject(UiSelector().descriptionContains(description)) + marker.click() + } + + override fun scrollTo(target: TestDriver.Target) { + wait(target) + if (target is TestDriver.Target.ViewId) { + onView(withId(target.resId)).perform(ViewActions.scrollTo()) + } else { + composeRule.onTarget(target).performScrollTo() + } + } + + override fun insertText(text: String, target: TestDriver.Target) { + wait(target) + if (target is TestDriver.Target.ViewId) { + onView(withId(target.resId)).perform(ViewActions.typeText(text)) + } else { + composeRule.onTarget(target).performTextInput(text) + } + } + + override fun takePhoto() { + val shutterSelector = By.res("com.android.camera2:id/shutter_button") + val doneSelector = By.res("com.android.camera2:id/done_button") + device.wait(Until.findObject(shutterSelector), SHORT_TIMEOUT).click() + device.wait(Until.findObject(doneSelector), SHORT_TIMEOUT).click() + } + + override fun setDate() { + val resName = composeRule.activity.resources.getResourceEntryName(R.id.user_date_response_text) + val packageName = composeRule.activity.packageName + val textInputField = device.findObject(By.res(packageName, resName)) + textInputField?.click() + + device.wait(Until.findObject(By.clazz(DatePicker::class.java)), SHORT_TIMEOUT) + device.findObject(By.text("OK")).click() + } + + override fun setTime() { + val resName = composeRule.activity.resources.getResourceEntryName(R.id.user_time_response_text) + val packageName = composeRule.activity.packageName + val textInputField = device.findObject(By.res(packageName, resName)) + textInputField?.click() + + device.wait(Until.findObject(By.clazz(TimePicker::class.java)), SHORT_TIMEOUT) + device.findObject(By.text("OK")).click() + } + + override fun getStringResource(id: Int): String = composeRule.activity.getString(id) +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt new file mode 100644 index 0000000000..e179f3c366 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/TestDriver.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.drivers + +interface TestDriver { + fun click(target: Target) + + fun selectFromList(target: Target, index: Int) + + fun dragMapBy(offsetX: Int, offsetY: Int) + + fun clickMapMarker(description: String) + + fun scrollTo(target: Target) + + fun insertText(text: String, target: Target) + + fun takePhoto() + + fun setDate() + + fun setTime() + + fun getStringResource(id: Int): String + + sealed class Target { + data class TestTag(val tag: String) : Target() + + data class ViewId(val resId: Int) : Target() + + data class Text(val text: String, val substring: Boolean = false) : Target() + + data class ContentDescription(val text: String) : Target() + } +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/extensions/ComposeTestRuleExtensions.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/extensions/ComposeTestRuleExtensions.kt new file mode 100644 index 0000000000..35a207e4b7 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/extensions/ComposeTestRuleExtensions.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.extensions + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import org.groundplatform.android.e2etest.drivers.TestDriver + +fun AndroidComposeTestRule<*, *>.onTarget( + target: TestDriver.Target, + index: Int = 0, +): SemanticsNodeInteraction = + when (target) { + is TestDriver.Target.ContentDescription -> onAllNodesWithContentDescription(target.text)[index] + is TestDriver.Target.TestTag -> onAllNodesWithTag(target.tag)[index] + is TestDriver.Target.Text -> onAllNodesWithText(target.text, target.substring)[index] + else -> throw IllegalArgumentException("Not a Compose node") + } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt new file mode 100644 index 0000000000..86605e8932 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.robots + +import org.groundplatform.android.R +import org.groundplatform.android.e2etest.TestConfig.LOI_NAME +import org.groundplatform.android.e2etest.TestTask +import org.groundplatform.android.e2etest.drivers.TestDriver +import org.groundplatform.android.model.task.Task +import org.groundplatform.android.ui.datacollection.components.LOI_NAME_TEXT_FIELD_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.OTHER_INPUT_TEXT_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.SELECT_MULTIPLE_CHECKBOX_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.multiplechoice.SELECT_MULTIPLE_RADIO_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.number.INPUT_NUMBER_TEST_TAG +import org.groundplatform.android.ui.datacollection.tasks.text.INPUT_TEXT_TEST_TAG + +class DataCollectionRobot(override val testDriver: TestDriver) : Robot() { + fun dismissInstructions() { + val buttonText = testDriver.getStringResource(R.string.close) + testDriver.click(TestDriver.Target.Text(buttonText)) + } + + fun runTasks(taskList: List) { + taskList.forEach { task -> + when (task.taskType) { + Task.Type.UNKNOWN -> + throw IllegalStateException( + "Something is wrong with the tasks defined in the Firebase emulator" + ) + Task.Type.TEXT -> textTask() + Task.Type.MULTIPLE_CHOICE -> multipleChoiceTask(task.selectIndexes!!) + Task.Type.PHOTO -> cameraTask() + Task.Type.NUMBER -> numberTask() + Task.Type.DATE -> dateTask() + Task.Type.TIME -> timeTask() + Task.Type.DROP_PIN -> dropPinTask() + Task.Type.DRAW_AREA -> drawAreaTask() + Task.Type.CAPTURE_LOCATION -> captureLocationTask() + Task.Type.INSTRUCTIONS -> { + /* Nothing to do, just read */ + } + } + + if (task == taskList.last()) { + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.done))) + } else { + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.next))) + } + + if (task.taskType == Task.Type.DRAW_AREA || task.taskType == Task.Type.DROP_PIN) { + nameLocation() + } + } + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.close))) + } + + private fun drawAreaTask(): DataCollectionRobot { + val points = listOf((0 to 0), (-500 to 0), (0 to -500), (500 to 0), (0 to 500)) + val nextButton = TestDriver.Target.Text(testDriver.getStringResource(R.string.add_point)) + val completeButton = + TestDriver.Target.Text(testDriver.getStringResource(R.string.complete_polygon)) + points.forEachIndexed { index, point -> + when (index) { + 0 -> testDriver.click(nextButton) + points.lastIndex -> { + testDriver.dragMapBy(point.first, point.second) + testDriver.click(completeButton) + } + else -> { + testDriver.dragMapBy(point.first, point.second) + testDriver.click(nextButton) + } + } + } + return this + } + + private fun dropPinTask(): DataCollectionRobot { + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.drop_pin))) + return this + } + + private fun textTask() { + testDriver.insertText("Test", TestDriver.Target.TestTag(INPUT_TEXT_TEST_TAG)) + } + + private fun numberTask() { + testDriver.insertText("2025", TestDriver.Target.TestTag(INPUT_NUMBER_TEST_TAG)) + } + + private fun nameLocation() { + testDriver.insertText( + text = LOI_NAME, + target = TestDriver.Target.TestTag(LOI_NAME_TEXT_FIELD_TEST_TAG), + ) + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.save))) + } + + private fun multipleChoiceTask(selectIndexes: List) { + if (selectIndexes.size == 1) { + testDriver.selectFromList( + TestDriver.Target.TestTag(SELECT_MULTIPLE_RADIO_TEST_TAG), + selectIndexes[0], + ) + } else { + selectIndexes.forEach { + testDriver.selectFromList(TestDriver.Target.TestTag(SELECT_MULTIPLE_CHECKBOX_TEST_TAG), it) + } + testDriver.insertText("Other", TestDriver.Target.TestTag(OTHER_INPUT_TEXT_TEST_TAG)) + } + } + + private fun cameraTask() { + testDriver.click(TestDriver.Target.ViewId(R.id.btn_camera)) + testDriver.takePhoto() + } + + private fun dateTask() { + testDriver.setDate() + } + + private fun timeTask() { + testDriver.setTime() + } + + private fun captureLocationTask() { + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.capture))) + } +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/HomeScreenRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/HomeScreenRobot.kt new file mode 100644 index 0000000000..2419f781c6 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/HomeScreenRobot.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.robots + +import androidx.compose.ui.test.ExperimentalTestApi +import org.groundplatform.android.R +import org.groundplatform.android.e2etest.drivers.TestDriver +import org.groundplatform.android.ui.components.LOCATION_NOT_LOCKED_TEST_TAG + +@OptIn(ExperimentalTestApi::class) +class HomeScreenRobot(override val testDriver: TestDriver) : Robot() { + fun moveMap() = testDriver.dragMapBy(500, 500) + + fun recenter() = testDriver.click(TestDriver.Target.TestTag(LOCATION_NOT_LOCKED_TEST_TAG)) + + fun addLoi() { + val contentDescription = testDriver.getStringResource(R.string.add_site) + testDriver.click(TestDriver.Target.ContentDescription(contentDescription)) + } + + fun selectJob(jobTitle: String) = testDriver.click(TestDriver.Target.Text(jobTitle)) + + fun acceptDataSharingTerms() { + val buttonText = testDriver.getStringResource(R.string.agree_checkbox) + testDriver.click(TestDriver.Target.Text(buttonText)) + } + + fun deleteLoi(name: String) { + testDriver.clickMapMarker(name) + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.delete_site))) + testDriver.click(TestDriver.Target.Text(testDriver.getStringResource(R.string.delete))) + } +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/Robot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/Robot.kt new file mode 100644 index 0000000000..f7e1273dfd --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/Robot.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.robots + +import org.groundplatform.android.e2etest.drivers.TestDriver + +abstract class Robot { + abstract val testDriver: TestDriver +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/SignInRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/SignInRobot.kt new file mode 100644 index 0000000000..c64c4dba48 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/SignInRobot.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.robots + +import org.groundplatform.android.e2etest.drivers.TestDriver +import org.groundplatform.android.ui.signin.BUTTON_TEST_TAG + +class SignInRobot(override val testDriver: TestDriver) : Robot() { + fun signIn() = testDriver.click(TestDriver.Target.TestTag(BUTTON_TEST_TAG)) +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/SurveySelectorRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/SurveySelectorRobot.kt new file mode 100644 index 0000000000..a239a8a674 --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/SurveySelectorRobot.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.robots + +import androidx.compose.ui.test.ExperimentalTestApi +import org.groundplatform.android.R +import org.groundplatform.android.e2etest.drivers.TestDriver + +@OptIn(ExperimentalTestApi::class) +class SurveySelectorRobot(override val testDriver: TestDriver) : Robot() { + fun expandPrivateSurveys() { + testDriver.click( + TestDriver.Target.Text( + text = testDriver.getStringResource(R.string.section_shared_with_me), + substring = true, + ) + ) + } + + fun selectSurvey(name: String) = testDriver.click(TestDriver.Target.Text(name)) +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/TermsOfServiceRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/TermsOfServiceRobot.kt new file mode 100644 index 0000000000..d0f8f2a5cb --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/TermsOfServiceRobot.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * https://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 org.groundplatform.android.e2etest.robots + +import org.groundplatform.android.R +import org.groundplatform.android.e2etest.drivers.TestDriver + +class TermsOfServiceRobot(override val testDriver: TestDriver) : Robot() { + fun agree(): TermsOfServiceRobot { + with(TestDriver.Target.Text(testDriver.getStringResource(R.string.agree_checkbox))) { + testDriver.scrollTo(this) + testDriver.click(this) + } + with(TestDriver.Target.Text(testDriver.getStringResource(R.string.agree_terms))) { + testDriver.scrollTo(this) + testDriver.click(this) + } + return this + } +} diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/tests/CompleteAllTaskTypesTest.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/tests/CompleteAllTaskTypesTest.kt new file mode 100644 index 0000000000..aed66dec1b --- /dev/null +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/tests/CompleteAllTaskTypesTest.kt @@ -0,0 +1,76 @@ +package org.groundplatform.android.e2etest.tests + +import android.Manifest +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice +import org.groundplatform.android.e2etest.TestConfig +import org.groundplatform.android.e2etest.drivers.AndroidTestDriver +import org.groundplatform.android.e2etest.robots.DataCollectionRobot +import org.groundplatform.android.e2etest.robots.HomeScreenRobot +import org.groundplatform.android.e2etest.robots.SignInRobot +import org.groundplatform.android.e2etest.robots.SurveySelectorRobot +import org.groundplatform.android.e2etest.robots.TermsOfServiceRobot +import org.groundplatform.android.ui.main.MainActivity +import org.groundplatform.android.ui.map.gms.features.TEST_MARKER_TAG +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CompleteAllTaskTypesTest { + + @get:Rule val composeTestRule = createAndroidComposeRule() + + @get:Rule + val runtimePermissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION) + + private lateinit var testDriver: AndroidTestDriver + + @Before + fun setup() { + testDriver = + AndroidTestDriver( + composeRule = composeTestRule, + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()), + ) + } + + @Test + fun run() { + SignInRobot(testDriver).signIn() + TermsOfServiceRobot(testDriver).agree() + with(SurveySelectorRobot(testDriver)) { + expandPrivateSurveys() + selectSurvey(TestConfig.SURVEY_NAME) + } + // Add new LOI and test all task types except DRAW_AREA + with(HomeScreenRobot(testDriver)) { + moveMap() + recenter() + addLoi() + selectJob(TestConfig.TEST_JOB_ALL_TASK_TYPES_EXCEPT_DRAW_AREA) + acceptDataSharingTerms() + } + with(DataCollectionRobot(testDriver)) { + dismissInstructions() + runTasks(TestConfig.TEST_LIST_ALL_TASK_TYPES_EXCEPT_DRAW_AREA) + } + // Remove previous LOI and add new one to test DRAW_AREA + with(HomeScreenRobot(testDriver)) { + moveMap() + recenter() + deleteLoi(TEST_MARKER_TAG) + addLoi() + selectJob(TestConfig.TEST_JOB_DRAW_AREA) + } + with(DataCollectionRobot(testDriver)) { + dismissInstructions() + runTasks(TestConfig.TEST_LIST_DRAW_AREA) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d96b83cef..0c28c69304 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ android-maps-utils = { module = "com.google.maps.android:android-maps-utils", ve androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompatVersion" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-core = { module = "androidx.test:core", version.ref = "coreVersion" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }