diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt index f4cf37997e..a819528377 100644 --- a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragment.kt @@ -15,20 +15,12 @@ */ package org.groundplatform.android.ui.datacollection.tasks.multiplechoice -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup import dagger.hilt.android.AndroidEntryPoint import org.groundplatform.android.ui.datacollection.tasks.AbstractTaskFragment -import org.groundplatform.ui.theme.sizes +import org.groundplatform.android.util.createComposeView const val MULTIPLE_CHOICE_LIST_TEST_TAG = "multiple choice items test tag" @@ -39,22 +31,15 @@ const val MULTIPLE_CHOICE_LIST_TEST_TAG = "multiple choice items test tag" @AndroidEntryPoint class MultipleChoiceTaskFragment : AbstractTaskFragment() { - @Composable - override fun TaskBody() { - val list by viewModel.items.collectAsStateWithLifecycle() - val scrollState = rememberLazyListState() - - Box(modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding)) { - LazyColumn(Modifier.testTag(MULTIPLE_CHOICE_LIST_TEST_TAG), state = scrollState) { - items(list, key = { it.option.id }) { item -> - MultipleChoiceItemView( - item = item, - isLastIndex = list.indexOf(item) == list.lastIndex, - toggleItem = { viewModel.onItemToggled(it) }, - otherValueChanged = { viewModel.onOtherTextChanged(it) }, - ) - } - } - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = createComposeView { + MultipleChoiceTaskScreen( + viewModel = viewModel, + onFooterPositionUpdated = { saveFooterPosition(it) }, + onAction = { handleTaskScreenAction(it) }, + ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreen.kt b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreen.kt new file mode 100644 index 0000000000..14cff73185 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreen.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2026 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.ui.datacollection.tasks.multiplechoice + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.datacollection.components.TaskHeader +import org.groundplatform.android.ui.datacollection.tasks.TaskScreen +import org.groundplatform.android.ui.datacollection.tasks.TaskScreenAction +import org.groundplatform.domain.model.task.MultipleChoice.Cardinality +import org.groundplatform.domain.model.task.Option +import org.groundplatform.ui.theme.AppTheme +import org.groundplatform.ui.theme.sizes + +@Composable +fun MultipleChoiceTaskScreen( + viewModel: MultipleChoiceTaskViewModel, + onFooterPositionUpdated: (Float) -> Unit, + onAction: (TaskScreenAction) -> Unit, +) { + val taskActionButtonsStates by viewModel.taskActionButtonStates.collectAsStateWithLifecycle() + val list by viewModel.items.collectAsStateWithLifecycle() + + TaskScreen( + taskHeader = + TaskHeader(label = viewModel.task.label, iconResId = R.drawable.ic_question_answer), + taskActionButtonsStates = taskActionButtonsStates, + onFooterPositionUpdated = onFooterPositionUpdated, + onAction = onAction, + taskBody = { + MultipleChoiceTaskContent( + list = list, + onItemToggled = { viewModel.onItemToggled(it) }, + onOtherValueChanged = { viewModel.onOtherTextChanged(it) }, + ) + }, + ) +} + +@Composable +internal fun MultipleChoiceTaskContent( + list: List, + onItemToggled: (MultipleChoiceItem) -> Unit, + onOtherValueChanged: (String) -> Unit, +) { + val scrollState = rememberLazyListState() + + Box(modifier = Modifier.padding(horizontal = MaterialTheme.sizes.taskViewPadding)) { + LazyColumn(Modifier.testTag(MULTIPLE_CHOICE_LIST_TEST_TAG), state = scrollState) { + items(list, key = { it.option.id }) { item -> + MultipleChoiceItemView( + item = item, + isLastIndex = list.indexOf(item) == list.lastIndex, + toggleItem = onItemToggled, + otherValueChanged = onOtherValueChanged, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +@ExcludeFromJacocoGeneratedReport +private fun MultipleChoiceTaskContentPreview() { + AppTheme { + Surface { + MultipleChoiceTaskContent( + list = + listOf( + MultipleChoiceItem( + option = Option("option id 1", "code1", "Option 1"), + cardinality = Cardinality.SELECT_ONE, + isSelected = true, + ), + MultipleChoiceItem( + option = Option("option id 2", "code2", "Option 2"), + cardinality = Cardinality.SELECT_ONE, + isSelected = false, + ), + MultipleChoiceItem( + option = Option("option id 3", "code3", "Other"), + cardinality = Cardinality.SELECT_ONE, + isSelected = false, + isOtherOption = true, + ), + ), + onItemToggled = {}, + onOtherValueChanged = {}, + ) + } + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragmentTest.kt deleted file mode 100644 index 23c8402399..0000000000 --- a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskFragmentTest.kt +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Copyright 2023 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.ui.datacollection.tasks.multiplechoice - -import android.content.Context -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidTest -import javax.inject.Inject -import kotlinx.collections.immutable.persistentListOf -import org.groundplatform.android.R -import org.groundplatform.android.common.Constants -import org.groundplatform.android.ui.common.ViewModelFactory -import org.groundplatform.android.ui.datacollection.DataCollectionViewModel -import org.groundplatform.android.ui.datacollection.components.ButtonAction -import org.groundplatform.android.ui.datacollection.components.ButtonActionState -import org.groundplatform.android.ui.datacollection.tasks.BaseTaskFragmentTest -import org.groundplatform.domain.model.job.Job -import org.groundplatform.domain.model.submission.MultipleChoiceTaskData -import org.groundplatform.domain.model.task.MultipleChoice -import org.groundplatform.domain.model.task.Option -import org.groundplatform.domain.model.task.Task -import org.junit.Assert.assertThrows -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.robolectric.RobolectricTestRunner -import org.robolectric.shadows.ShadowAlertDialog - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -class MultipleChoiceTaskFragmentTest : - BaseTaskFragmentTest() { - - @Inject @ApplicationContext lateinit var context: Context - @BindValue @Mock override lateinit var dataCollectionViewModel: DataCollectionViewModel - @Inject override lateinit var viewModelFactory: ViewModelFactory - - private val task = - Task( - id = "task_1", - index = 0, - type = Task.Type.MULTIPLE_CHOICE, - label = "Text label", - isRequired = false, - multipleChoice = MultipleChoice(persistentListOf(), MultipleChoice.Cardinality.SELECT_ONE), - ) - private val job = Job(id = "job1") - - private val options = - persistentListOf( - Option("option id 1", "code1", "Option 1"), - Option("option id 2", "code2", "Option 2"), - ) - - @Test - fun `fails when multiple choice is null`() { - assertThrows(IllegalStateException::class.java) { - setupTaskFragment(job, task.copy(multipleChoice = null)) - } - } - - @Test - fun `renders header`() { - setupTaskFragment(job, task) - - hasTaskViewWithHeader(task) - } - - @Test - fun `renders SELECT_ONE options`() { - setupTaskFragment( - job, - task.copy(multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE)), - ) - - runner().assertOptionsDisplayed("Option 1", "Option 2") - } - - @Test - fun `renders SELECT_MULTIPLE options`() { - setupTaskFragment( - job, - task.copy( - multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_MULTIPLE) - ), - ) - - runner().assertOptionsDisplayed("Option 1", "Option 2") - } - - @Test - fun `allows only one selection for SELECT_ONE cardinality`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE) - setupTaskFragment(job, task.copy(multipleChoice = multipleChoice)) - - runner() - .assertButtonIsDisabled("Next") - .selectOption("Option 1") - .selectOption("Option 2") - .assertButtonIsEnabled("Next") - - hasValue(MultipleChoiceTaskData(multipleChoice, listOf("option id 2"))) - } - - @Test - fun `allows multiple selection for SELECT_MULTIPLE cardinality`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_MULTIPLE) - setupTaskFragment(job, task.copy(multipleChoice = multipleChoice)) - - runner().selectOption("Option 1").selectOption("Option 2").assertButtonIsEnabled("Next") - - hasValue(MultipleChoiceTaskData(multipleChoice, listOf("option id 1", "option id 2"))) - } - - @Test - fun `saves other text`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_MULTIPLE, true) - setupTaskFragment( - job, - task.copy(multipleChoice = multipleChoice, isRequired = true), - ) - val userInput = "User inputted text" - - runner().selectOption("Other").inputOtherText(userInput).assertButtonIsEnabled("Next") - - hasValue(MultipleChoiceTaskData(multipleChoice, listOf("[ $userInput ]"))) - } - - @Test - fun `text over the character limit is invalid`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_MULTIPLE, true) - setupTaskFragment( - job, - task.copy(multipleChoice = multipleChoice, isRequired = true), - ) - val userInput = "a".repeat(Constants.TEXT_DATA_CHAR_LIMIT + 1) - // TODO: We should actually validate that the error toast is displayed after Next is clicked. - // Unfortunately, matching toasts with espresso is not straightforward, so we leave it at - // an explicit validation check for now. - runner().selectOption("Other").inputOtherText(userInput) - assertThat(viewModel.validate()).isEqualTo(R.string.text_task_data_character_limit) - } - - @Test - fun `selects other option on text input and deselects other radio inputs`() = - runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE, true) - setupTaskFragment(job, task.copy(multipleChoice = multipleChoice)) - - val userInput = "A" - - runner() - .selectOption("Option 1") - .assertOptionNotSelected("Other") - .inputOtherText(userInput) - .assertOptionNotSelected("Option 1") - .assertOptionSelected("Other") - - hasValue(MultipleChoiceTaskData(multipleChoice, listOf("[ $userInput ]"))) - } - - @Test - fun `deselects other option on text clear and required`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE, true) - setupTaskFragment( - job, - task.copy(multipleChoice = multipleChoice, isRequired = true), - ) - - runner() - .assertOptionNotSelected("Other") - .inputOtherText("A") - .assertOptionSelected("Other") - .clearOtherText() - .assertOptionNotSelected("Other") - } - - @Test - fun `no deselection of other option on text clear when not required`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE, true) - setupTaskFragment(job, task.copy(multipleChoice = multipleChoice)) - - runner() - .assertOptionNotSelected("Other") - .inputOtherText("A") - .assertOptionSelected("Other") - .clearOtherText() - .assertOptionSelected("Other") - } - - @Test - fun `no deselection of non-other selection when other is cleared`() = runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE, true) - setupTaskFragment( - job, - task.copy(multipleChoice = multipleChoice, isRequired = true), - ) - - runner() - .inputOtherText("A") - .selectOption("Option 1") - .assertOptionNotSelected("Other") - .assertOptionSelected("Option 1") - .clearOtherText() - .assertOptionSelected("Option 1") - .assertOptionNotSelected("Other") - } - - @Test - fun `Initial action buttons state when task is optional`() { - setupTaskFragment(job, task) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), - ) - } - - @Test - fun `Initial action buttons state when task is the first and optional`() { - setupTaskFragment(job, task, isFistPosition = true) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = false, isVisible = true), - ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), - ) - } - - @Test - fun `Initial action buttons state when it's the last task and optional`() { - setupTaskFragment(job, task, isLastPosition = true) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), - ButtonActionState(ButtonAction.DONE, isEnabled = false, isVisible = true), - ) - } - - @Test - fun `Initial action buttons state when it's the first task and required`() { - setupTaskFragment( - job, - task.copy(isRequired = true), - isFistPosition = true, - ) - - assertFragmentHasButtons( - ButtonActionState(ButtonAction.PREVIOUS, isEnabled = false, isVisible = true), - ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), - ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), - ) - } - - @Test - fun `hides skip button when option is selected`() { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE) - setupTaskFragment(job, task.copy(multipleChoice = multipleChoice)) - - runner().selectOption("Option 1").assertButtonIsHidden("Skip") - } - - @Test - fun `no confirmation dialog shown when no data is entered and skipped`() { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE) - setupTaskFragment(job, task.copy(multipleChoice = multipleChoice)) - - runner().clickButton("Skip") - - assertThat(ShadowAlertDialog.getShownDialogs().isEmpty()).isTrue() - } - - @Test - fun `doesn't save response when other text is missing and task is required`() = - runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_ONE, true) - setupTaskFragment( - job, - task.copy(multipleChoice = multipleChoice, isRequired = true), - ) - - runner().selectOption("Other").inputOtherText("").assertButtonIsDisabled("Next") - - hasValue(null) - } - - @Test - fun `doesn't save response when multiple options selected but other text is missing and task is required`() = - runWithTestDispatcher { - val multipleChoice = MultipleChoice(options, MultipleChoice.Cardinality.SELECT_MULTIPLE, true) - setupTaskFragment( - job, - task.copy(multipleChoice = multipleChoice, isRequired = true), - ) - - runner() - .selectOption("Option 1") - .selectOption("Option 2") - .selectOption("Other") - .assertButtonIsDisabled("Next") - - hasValue(null) - } -} diff --git a/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt new file mode 100644 index 0000000000..3107ae022d --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/datacollection/tasks/multiplechoice/MultipleChoiceTaskScreenTest.kt @@ -0,0 +1,295 @@ +/* + * Copyright 2026 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.ui.datacollection.tasks.multiplechoice + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.google.common.truth.Truth.assertThat +import kotlinx.collections.immutable.persistentListOf +import org.groundplatform.android.R +import org.groundplatform.android.common.Constants +import org.groundplatform.android.ui.datacollection.components.ButtonAction +import org.groundplatform.android.ui.datacollection.components.ButtonActionState +import org.groundplatform.android.ui.datacollection.tasks.ButtonActionStateChecker +import org.groundplatform.android.ui.datacollection.tasks.TaskPositionInterface +import org.groundplatform.android.ui.datacollection.tasks.TaskScreenAction +import org.groundplatform.domain.model.job.Job +import org.groundplatform.domain.model.submission.MultipleChoiceTaskData +import org.groundplatform.domain.model.submission.TaskData +import org.groundplatform.domain.model.task.MultipleChoice +import org.groundplatform.domain.model.task.Option +import org.groundplatform.domain.model.task.Task +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MultipleChoiceTaskScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var viewModel: MultipleChoiceTaskViewModel + private var lastButtonAction: ButtonAction? = null + private val buttonActionStateChecker = ButtonActionStateChecker(composeTestRule) + + private fun setupTaskScreen( + task: Task, + taskData: TaskData? = null, + isFirst: Boolean = false, + isLastWithValue: Boolean = false, + ) { + lastButtonAction = null + viewModel = MultipleChoiceTaskViewModel() + viewModel.initialize( + job = JOB, + task = task, + taskData = taskData, + taskPositionInterface = + object : TaskPositionInterface { + override fun isFirst() = isFirst + + override fun isLastWithValue(taskData: TaskData?) = isLastWithValue + }, + ) + + composeTestRule.setContent { + MultipleChoiceTaskScreen( + viewModel = viewModel, + onFooterPositionUpdated = {}, + onAction = { + if (it is TaskScreenAction.OnButtonClicked) { + lastButtonAction = it.action + } + }, + ) + } + } + + @Test + fun `displays task header when task is loaded`() { + setupTaskScreen(TASK) + + composeTestRule.onNodeWithText("Text label").assertIsDisplayed() + } + + @Test + fun `renders select one options`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Option 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Option 2").assertIsDisplayed() + } + + @Test + fun `renders select multiple options`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_MULTIPLE) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Option 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Option 2").assertIsDisplayed() + } + + @Test + fun `allows only one selection for select one cardinality`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Option 1").performClick() + composeTestRule.onNodeWithText("Option 2").performClick() + + val taskData = viewModel.taskTaskData.value as MultipleChoiceTaskData + assertThat(taskData.selectedOptionIds).containsExactly("option id 2") + } + + @Test + fun `allows multiple selection for select multiple cardinality`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_MULTIPLE) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Option 1").performClick() + composeTestRule.onNodeWithText("Option 2").performClick() + + val taskData = viewModel.taskTaskData.value as MultipleChoiceTaskData + assertThat(taskData.selectedOptionIds).containsExactly("option id 1", "option id 2") + } + + @Test + fun `saves other text`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_MULTIPLE, true) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Other").performClick() + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput("User text") + + val taskData = viewModel.taskTaskData.value as MultipleChoiceTaskData + assertThat(taskData.selectedOptionIds).containsExactly("[ User text ]") + } + + @Test + fun `sets initial action buttons state when task is optional`() { + setupTaskScreen(TASK) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `text over the character limit is invalid`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_MULTIPLE, true) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + val userInput = "a".repeat(Constants.TEXT_DATA_CHAR_LIMIT + 1) + composeTestRule.onNodeWithText("Other").performClick() + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput(userInput) + + assertThat(viewModel.validate(TASK, viewModel.taskTaskData.value)) + .isEqualTo(R.string.text_task_data_character_limit) + } + + @Test + fun `selects other option on text input and deselects other radio inputs`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE, true) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Option 1").performClick() + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput("A") + + val taskData = viewModel.taskTaskData.value as MultipleChoiceTaskData + assertThat(taskData.selectedOptionIds).containsExactly("[ A ]") + } + + @Test + fun `deselects other option on text clear and required`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE, true) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice, isRequired = true)) + + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput("A") + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextClearance() + + assertThat(viewModel.taskTaskData.value).isNull() + } + + @Test + fun `no deselection of other option on text clear when not required`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE, true) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput("A") + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextClearance() + + val taskData = viewModel.taskTaskData.value as MultipleChoiceTaskData + assertThat(taskData.selectedOptionIds).containsExactly("[ ]") + } + + @Test + fun `no deselection of non-other selection when other is cleared`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE, true) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice, isRequired = true)) + + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput("A") + composeTestRule.onNodeWithText("Option 1").performClick() + composeTestRule.onNodeWithTag(OTHER_INPUT_TEXT_TEST_TAG).performTextInput("") + + val taskData = viewModel.taskTaskData.value as MultipleChoiceTaskData + assertThat(taskData.selectedOptionIds).containsExactly("option id 1") + } + + @Test + fun `hides skip button when option is selected`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice)) + + composeTestRule.onNodeWithText("Option 1").performClick() + + buttonActionStateChecker.getNode(ButtonAction.SKIP).assertDoesNotExist() + } + + @Test + fun `sets initial action buttons state when task is required`() { + setupTaskScreen(TASK.copy(isRequired = true)) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `sets action buttons state when it is the first task`() { + setupTaskScreen(TASK, isFirst = true) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = false, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.NEXT, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `sets action buttons state when it is the last task`() { + setupTaskScreen(TASK, isLastWithValue = true) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.DONE, isEnabled = false, isVisible = true), + ) + } + + @Test + fun `sets action buttons state when data is pre-filled`() { + val multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE) + val taskData = MultipleChoiceTaskData(multipleChoice, listOf("option id 1")) + setupTaskScreen(TASK.copy(multipleChoice = multipleChoice), taskData = taskData) + + buttonActionStateChecker.assertButtonStates( + ButtonActionState(ButtonAction.PREVIOUS, isEnabled = true, isVisible = true), + ButtonActionState(ButtonAction.SKIP, isEnabled = false, isVisible = false), + ButtonActionState(ButtonAction.NEXT, isEnabled = true, isVisible = true), + ) + } + + companion object { + private val JOB = Job(id = "job1") + + private val OPTIONS = + persistentListOf( + Option("option id 1", "code1", "Option 1"), + Option("option id 2", "code2", "Option 2"), + ) + + private val TASK = + Task( + id = "task_1", + index = 0, + type = Task.Type.MULTIPLE_CHOICE, + label = "Text label", + isRequired = false, + multipleChoice = MultipleChoice(OPTIONS, MultipleChoice.Cardinality.SELECT_ONE), + ) + } +}