diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt index 569b654da8..a7cd80d3aa 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator.validateQuestionnaireResponseStructure import com.google.android.fhir.datacapture.views.QuestionnaireItemViewItem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -79,7 +80,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat questionnaireResponse = FhirContext.forR4().newJsonParser().parseResource(questionnaireJsonResponseString) as QuestionnaireResponse - validateQuestionnaireResponseItems(questionnaire.item, questionnaireResponse.item) + validateQuestionnaireResponseStructure(questionnaire, questionnaireResponse) } else { questionnaireResponse = QuestionnaireResponse().apply { @@ -249,18 +250,25 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat pagination: QuestionnairePagination?, ): QuestionnaireState { // TODO(kmost): validate pages before switching between next/prev pages + var responseIndex = 0 val items: List = questionnaireItemList .asSequence() - .withIndex() - .zip(questionnaireResponseItemList.asSequence()) - .flatMap { (questionnaireItemAndIndex, questionnaireResponseItem) -> - val (index, questionnaireItem) = questionnaireItemAndIndex - + .flatMapIndexed { index, questionnaireItem -> + var questionnaireResponseItem = questionnaireItem.createQuestionnaireResponseItem() + + // If there is an enabled questionnaire response available then we use that. Or else we + // just use an empty questionnaireResponse Item + if (responseIndex < questionnaireResponseItemList.size && + questionnaireItem.linkId == questionnaireResponseItem.linkId + ) { + questionnaireResponseItem = questionnaireResponseItemList[responseIndex] + responseIndex += 1 + } // if the questionnaire is paginated and we're currently working through the paginated // groups, make sure that only the current page gets set if (pagination != null && pagination.currentPageIndex != index) { - return@flatMap emptyList() + return@flatMapIndexed emptyList() } val enabled = @@ -269,7 +277,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } if (!enabled || questionnaireItem.isHidden) { - return@flatMap emptyList() + return@flatMapIndexed emptyList() } listOf( @@ -321,54 +329,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat .toList() } - /** - * Traverse (DFS) through the list of questionnaire items and the list of questionnaire response - * items and check if the linkId of the matching pairs of questionnaire item and questionnaire - * response item are equal. The traverse is carried out in the two lists in tandem. The two lists - * should be structurally identical. - */ - private fun validateQuestionnaireResponseItems( - questionnaireItemList: List, - questionnaireResponseItemList: List - ) { - val questionnaireItemListIterator = questionnaireItemList.iterator() - val questionnaireResponseItemListIterator = questionnaireResponseItemList.iterator() - while (questionnaireItemListIterator.hasNext() && - questionnaireResponseItemListIterator.hasNext()) { - // TODO: Validate type and item nesting within answers for repeated answers - // https://github.com/google/android-fhir/issues/286 - val questionnaireItem = questionnaireItemListIterator.next() - val questionnaireResponseItem = questionnaireResponseItemListIterator.next() - if (!questionnaireItem.linkId.equals(questionnaireResponseItem.linkId)) - throw IllegalArgumentException( - "Mismatching linkIds for questionnaire item ${questionnaireItem.linkId} and " + - "questionnaire response item ${questionnaireResponseItem.linkId}" - ) - val type = checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" } - if (type == Questionnaire.QuestionnaireItemType.GROUP) { - validateQuestionnaireResponseItems(questionnaireItem.item, questionnaireResponseItem.item) - } else { - if (questionnaireResponseItem.answer.isNotEmpty()) - validateQuestionnaireResponseItems( - questionnaireItem.item, - questionnaireResponseItem.answer.first().item - ) - } - } - if (questionnaireItemListIterator.hasNext() xor questionnaireResponseItemListIterator.hasNext() - ) { - if (questionnaireItemListIterator.hasNext()) { - throw IllegalArgumentException( - "No matching questionnaire response item for questionnaire item ${questionnaireItemListIterator.next().linkId}" - ) - } else { - throw IllegalArgumentException( - "No matching questionnaire item for questionnaire response item ${questionnaireResponseItemListIterator.next().linkId}" - ) - } - } - } - /** * Checks if this questionnaire uses pagination via the "page" extension. * diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt index b41923d055..12d0de574b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt @@ -30,7 +30,7 @@ object QuestionnaireResponseValidator { * Validates [questionnaireResponseItemList] using the constraints defined in the * [questionnaireItemList]. */ - fun validate( + fun validateQuestionnaireResponseAnswers( questionnaireItemList: List, questionnaireResponseItemList: List, context: Context @@ -53,10 +53,104 @@ object QuestionnaireResponseValidator { ) if (questionnaireItem.hasNestedItemsWithinAnswers) { // TODO(https://github.com/google/android-fhir/issues/487): Validates all answers. - validate(questionnaireItem.item, questionnaireResponseItem.answer[0].item, context) + validateQuestionnaireResponseAnswers( + questionnaireItem.item, + questionnaireResponseItem.answer[0].item, + context + ) } - validate(questionnaireItem.item, questionnaireResponseItem.item, context) + validateQuestionnaireResponseAnswers( + questionnaireItem.item, + questionnaireResponseItem.item, + context + ) } return linkIdToValidationResultMap } + + /** + * Traverse (DFS) through the [Questionnaire.item] list and the [QuestionnaireResponse.item] list + * to check if the linkId of the matching pairs of questionnaire item and questionnaire response + * item are equal. + */ + fun validateQuestionnaireResponseStructure( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse + ) { + validateQuestionnaireResponseItemsStructurally(questionnaire.item, questionnaireResponse.item) + } + + private fun validateQuestionnaireResponseItemsStructurally( + questionnaireItemList: List, + questionnaireResponseInputItemList: + List, + ) { + val questionnaireResponseInputItemListIterator = questionnaireResponseInputItemList.iterator() + val questionnaireItemListIterator = questionnaireItemList.iterator() + + while (questionnaireResponseInputItemListIterator.hasNext()) { + // TODO: Validate type and item nesting within answers for repeated answers + // https://github.com/google/android-fhir/issues/286 + val questionnaireResponseInputItem = questionnaireResponseInputItemListIterator.next() + if (questionnaireItemListIterator.hasNext()) { + val questionnaireItem = questionnaireItemListIterator.next() + require(questionnaireItem.linkId == questionnaireResponseInputItem.linkId) { + "Mismatching linkIds for questionnaire item ${questionnaireItem.linkId} and " + + "questionnaire response item ${questionnaireResponseInputItem.linkId}" + } + val type = checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" } + if (questionnaireResponseInputItem.hasAnswer() && + type != Questionnaire.QuestionnaireItemType.GROUP + ) { + if (!questionnaireItem.repeats && questionnaireResponseInputItem.answer.size > 1) { + throw IllegalArgumentException( + "Multiple answers in ${questionnaireResponseInputItem.linkId} and repeats false in " + + "questionnaire item ${questionnaireItem.linkId}" + ) + } + questionnaireResponseInputItem.answer.forEachIndexed { + index, + questionnaireResponseItemAnswerComponent -> + if (questionnaireResponseItemAnswerComponent.hasValue()) { + when (type) { + Questionnaire.QuestionnaireItemType.BOOLEAN, + Questionnaire.QuestionnaireItemType.DECIMAL, + Questionnaire.QuestionnaireItemType.INTEGER, + Questionnaire.QuestionnaireItemType.DATE, + Questionnaire.QuestionnaireItemType.DATETIME, + Questionnaire.QuestionnaireItemType.TIME, + Questionnaire.QuestionnaireItemType.STRING, + Questionnaire.QuestionnaireItemType.URL -> + if (!questionnaireResponseItemAnswerComponent + .value + .fhirType() + .equals(type.toCode()) + ) { + throw IllegalArgumentException( + "Type mismatch for linkIds for questionnaire item ${questionnaireItem.linkId} and " + + "questionnaire response item ${questionnaireResponseInputItem.linkId}" + ) + } + else -> Unit // Check type for primitives only + } + } + validateQuestionnaireResponseItemsStructurally( + questionnaireItem.item, + questionnaireResponseItemAnswerComponent.item + ) + } + } else if (questionnaireResponseInputItem.hasItem()) { + validateQuestionnaireResponseItemsStructurally( + questionnaireItem.item, + questionnaireResponseInputItem.item + ) + } + } else { + // Input response has more items + throw IllegalArgumentException( + "No matching questionnaire item for questionnaire response item ${questionnaireResponseInputItem.linkId}" + ) + } + } + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index 154b1d103b..2e31f40490 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt @@ -157,7 +157,7 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS } @Test - fun stateHasQuestionnaireResponse_nestedItemsWithinGroupItems_shouldNotThrowException() { // ktlint-disable max-line-length + fun stateHasQuestionnaireResponse_nestedItemsWithinGroupItems_shouldNotThrowException() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -269,6 +269,45 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS createQuestionnaireViewModel(questionnaire, questionnaireResponse) } + @Test + fun stateHasQuestionnaireResponse_nonPrimitiveType_shouldNotThrowError() { + val testOption1 = Coding("test", "option", "1") + val testOption2 = Coding("test", "option", "2") + + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-link-id" + text = "Basic question" + type = Questionnaire.QuestionnaireItemType.CHOICE + answerOption = + listOf( + Questionnaire.QuestionnaireItemAnswerOptionComponent(testOption1), + Questionnaire.QuestionnaireItemAnswerOptionComponent(testOption2) + ) + } + ) + } + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-link-id" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = testOption1 + } + ) + } + ) + } + + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } + @Test fun stateHasQuestionnaireResponse_wrongLinkId_shouldThrowError() { val questionnaire = @@ -311,7 +350,8 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS } @Test - fun stateHasQuestionnaireResponse_lessItemsInQuestionnaireResponse_shouldThrowError() { + fun stateHasQuestionnaireResponse_lessItemsInQuestionnaireResponse_shouldAddTheMissingItem() = + runBlocking { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -320,10 +360,165 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS linkId = "a-link-id" text = "Basic question" type = Questionnaire.QuestionnaireItemType.BOOLEAN + initial = listOf(Questionnaire.QuestionnaireItemInitialComponent(BooleanType(true))) } ) } val questionnaireResponse = QuestionnaireResponse().apply { id = "a-questionnaire-response" } + val questionnaireResponseWithMissingItem = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-link-id" + answer = + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + } + + val questionnaireViewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + val questionnaireItemViewItem = questionnaireViewModel.questionnaireStateFlow.first() + assertThat(questionnaireItemViewItem.items.first().questionnaireResponseItem.linkId) + .isEqualTo(questionnaireResponseWithMissingItem.item.first().linkId) + assertThat( + questionnaireItemViewItem + .items + .first() + .questionnaireResponseItem + .answer + .first() + .valueBooleanType + .booleanValue() + ) + .isEqualTo( + questionnaireResponseWithMissingItem + .item + .first() + .answer + .first() + .valueBooleanType + .booleanValue() + ) + } + + @Test + fun stateHasQuestionnaireResponse_wrongType_shouldThrowError() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-link-id" + text = "Basic question" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + } + ) + } + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-link-id" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("true") + } + ) + } + ) + } + + val errorMessage = + assertFailsWith { + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } + .localizedMessage + + assertThat(errorMessage) + .isEqualTo( + "Type mismatch for linkIds for questionnaire item a-link-id and " + + "questionnaire response item a-link-id" + ) + } + + @Test + fun stateHasQuestionnaireResponse_repeatsTrueWithMultipleAnswers_shouldNotThrowError() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-link-id" + text = "Basic question which allows multiple answers" + type = Questionnaire.QuestionnaireItemType.STRING + repeats = true + } + ) + } + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-link-id" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("string 1") + } + ) + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("string 2") + } + ) + } + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + + assertResourceEquals(questionnaireResponse, viewModel.getQuestionnaireResponse()) + } + + @Test + fun stateHasQuestionnaireResponse_repeatsFalseWithMultipleAnswers_shouldThrowError() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-link-id" + text = "Basic question" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + repeats = false + } + ) + } + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-link-id" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(false) + } + ) + } + ) + } val errorMessage = assertFailsWith { @@ -332,7 +527,7 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS .localizedMessage assertThat(errorMessage) - .isEqualTo("No matching questionnaire response item for questionnaire item a-link-id") + .isEqualTo("Multiple answers in a-link-id and repeats false in questionnaire item a-link-id") } @Test @@ -414,7 +609,7 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS } @Test - fun questionnaireHasInitialValueButQuestionnareResponseAsEmpty_shouldSetEmptyAnswer() { + fun questionnaireHasInitialValueButQuestionnaireResponseAsEmpty_shouldSetEmptyAnswer() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -448,7 +643,7 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS } @Test - fun questionnareHasMoreThanOneInitialValuesAndNotRepeating_shouldThrowError() { + fun questionnaireHasMoreThanOneInitialValuesAndNotRepeating_shouldThrowError() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -508,7 +703,7 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS } @Test - fun questionnareHasInitialValueAndGroupType_shouldThrowError() { + fun questionnaireHasInitialValueAndGroupType_shouldThrowError() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -536,7 +731,7 @@ class QuestionnaireViewModelTest(private val questionnaireSource: QuestionnaireS } @Test - fun questionnareHasInitialValueAndDisplayType_shouldThrowError() { + fun questionnaireHasInitialValueAndDisplayType_shouldThrowError() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt index 762b918be7..fe8bfe8d71 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt @@ -65,7 +65,7 @@ class QuestionnaireResponseValidatorTest { ) ) val result = - QuestionnaireResponseValidator.validate( + QuestionnaireResponseValidator.validateQuestionnaireResponseAnswers( questionnaire.item, questionnaireResponse.item, context @@ -97,7 +97,7 @@ class QuestionnaireResponseValidatorTest { ) ) val result = - QuestionnaireResponseValidator.validate( + QuestionnaireResponseValidator.validateQuestionnaireResponseAnswers( questionnaire.item, questionnaireResponse.item, context @@ -154,7 +154,7 @@ class QuestionnaireResponseValidatorTest { ) ) val result = - QuestionnaireResponseValidator.validate( + QuestionnaireResponseValidator.validateQuestionnaireResponseAnswers( questionnaire.item, questionnaireResponse.item, context diff --git a/datacapturegallery/src/main/java/com/google/android/fhir/datacapture/gallery/QuestionnaireViewModel.kt b/datacapturegallery/src/main/java/com/google/android/fhir/datacapture/gallery/QuestionnaireViewModel.kt index baf6b2a6ae..bc6a50befa 100644 --- a/datacapturegallery/src/main/java/com/google/android/fhir/datacapture/gallery/QuestionnaireViewModel.kt +++ b/datacapturegallery/src/main/java/com/google/android/fhir/datacapture/gallery/QuestionnaireViewModel.kt @@ -52,19 +52,17 @@ class QuestionnaireViewModel(application: Application, private val state: SavedS } } - suspend fun getQuestionnaireResponse() = - withContext(backgroundContext) { + suspend fun getQuestionnaireResponse(): String? { + return withContext(backgroundContext) { state.get(QuestionnaireContainerFragment.QUESTIONNAIRE_RESPONSE_FILE_PATH_KEY)?.let { path -> - questionnaireResponseJson?.let { cachedResponse -> - questionnaireResponseJson = - questionnaireResponseJson?.let { cachedResponse } ?: readFileFromAssets(path) - questionnaireResponseJson + if (questionnaireResponseJson == null) { + questionnaireResponseJson = readFileFromAssets(path) } } - ?: null + questionnaireResponseJson } - + } private suspend fun readFileFromAssets(filename: String) = withContext(backgroundContext) { getApplication().assets.open(filename).bufferedReader().use { it.readText() }