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 616549caf7..e0a74edcad 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-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. @@ -151,10 +151,12 @@ object QuestionnaireResponseValidator { questionnaireResponseItemValidator: QuestionnaireResponseItemValidator, linkIdToValidationResultMap: MutableMap>, ): Map> { - when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) { - Questionnaire.QuestionnaireItemType.DISPLAY, - Questionnaire.QuestionnaireItemType.NULL, -> Unit - Questionnaire.QuestionnaireItemType.GROUP -> + checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" } + when { + questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY || + questionnaireItem.type == Questionnaire.QuestionnaireItemType.NULL -> Unit + questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP && + !questionnaireItem.repeats -> // Nested items under group // http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item validateQuestionnaireResponseItems( @@ -262,10 +264,13 @@ object QuestionnaireResponseValidator { questionnaireItem: Questionnaire.QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, ) { - when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) { - Questionnaire.QuestionnaireItemType.DISPLAY, - Questionnaire.QuestionnaireItemType.NULL, -> Unit - Questionnaire.QuestionnaireItemType.GROUP -> + checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" } + + when { + questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY || + questionnaireItem.type == Questionnaire.QuestionnaireItemType.NULL -> Unit + questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP && + !questionnaireItem.repeats -> // Nested items under group // http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item checkQuestionnaireResponseItems(questionnaireItem.item, questionnaireResponseItem.item) 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 6b3dfacf26..36fd666d41 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * Copyright 2022-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. @@ -20,6 +20,7 @@ import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL +import com.google.android.fhir.datacapture.extensions.packRepeatedGroups import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import kotlinx.coroutines.test.runTest @@ -596,6 +597,79 @@ class QuestionnaireResponseValidatorTest { ) } + @Test + fun `validation fails for required item in a questionnaire repeating group item with answer value`() { + val questionnaire1 = + Questionnaire().apply { + url = "questionnaire-1" + addItem( + Questionnaire.QuestionnaireItemComponent( + StringType("group-1"), + Enumeration( + Questionnaire.QuestionnaireItemTypeEnumFactory(), + Questionnaire.QuestionnaireItemType.GROUP, + ), + ) + .apply { + repeats = true + addItem( + Questionnaire.QuestionnaireItemComponent( + StringType("question-0"), + Enumeration( + Questionnaire.QuestionnaireItemTypeEnumFactory(), + Questionnaire.QuestionnaireItemType.INTEGER, + ), + ) + .apply { required = true }, + ) + }, + ) + } + + val questionnaireResponse1 = + QuestionnaireResponse() + .apply { + questionnaire = "questionnaire-1" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("group-1")).apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("question-0")) + .apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = IntegerType(1) + }, + ) + }, + ) + }, + ) + + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("group-1")).apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("question-0")), + ) + }, + ) + } + .apply { packRepeatedGroups(questionnaire1) } + + runTest { + val result = + QuestionnaireResponseValidator.validateQuestionnaireResponse( + questionnaire1, + questionnaireResponse1, + context, + ) + + assertThat(result.keys).containsExactly("question-0", "group-1") + assertThat(result["question-0"]!!.first()).isInstanceOf(Invalid::class.java) + assertThat((result["question-0"]!!.first() as Invalid).getSingleStringValidationMessage()) + .isEqualTo("Missing answer for required field.") + } + } + @Test fun `check passes if questionnaire response matches questionnaire`() { QuestionnaireResponseValidator.checkQuestionnaireResponse( @@ -1653,6 +1727,69 @@ class QuestionnaireResponseValidatorTest { ) } + @Test + fun `check fails for wrong answer type to a nested question in repeating group`() { + assertException_checkQuestionnaireResponse_throwsIllegalArgumentException( + Questionnaire().apply { + url = "questionnaire-1" + addItem( + Questionnaire.QuestionnaireItemComponent( + StringType("group-1"), + Enumeration( + Questionnaire.QuestionnaireItemTypeEnumFactory(), + Questionnaire.QuestionnaireItemType.GROUP, + ), + ) + .apply { + repeats = true + addItem( + Questionnaire.QuestionnaireItemComponent( + StringType("question-0"), + Enumeration( + Questionnaire.QuestionnaireItemTypeEnumFactory(), + Questionnaire.QuestionnaireItemType.INTEGER, + ), + ), + ) + }, + ) + }, + QuestionnaireResponse().apply { + questionnaire = "questionnaire-1" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("group-1")).apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("question-0")) + .apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = IntegerType(1) + }, + ) + }, + ) + }, + ) + + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("group-1")).apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("question-0")) + .apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DecimalType(2.0) + }, + ) + }, + ) + }, + ) + }, + "Mismatching question type INTEGER and answer type decimal for question-0", + ) + } + private fun assertException_checkQuestionnaireResponse_throwsIllegalArgumentException( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse,