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 91dc1868a5..36e3cd9c18 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 @@ -134,6 +134,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat questionnaireResponse = parser.parseResource(application.contentResolver.openInputStream(uri)) as QuestionnaireResponse + addMissingResponseItems(questionnaire.item, questionnaireResponse.item) checkQuestionnaireResponse(questionnaire, questionnaireResponse) } state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING) -> { @@ -141,6 +142,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING]!! questionnaireResponse = parser.parseResource(questionnaireResponseJson) as QuestionnaireResponse + addMissingResponseItems(questionnaire.item, questionnaireResponse.item) checkQuestionnaireResponse(questionnaire, questionnaireResponse) } else -> { @@ -346,6 +348,41 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat private val answerExpressionMap = mutableMapOf>() + /** + * Adds empty [QuestionnaireResponseItemComponent]s to `responseItems` so that each + * [QuestionnaireItemComponent] in `questionnaireItems` has at least one corresponding + * [QuestionnaireResponseItemComponent]. This is because user-provided [QuestionnaireResponse] + * might not contain answers to unanswered or disabled questions. Note : this only applies to + * [QuestionnaireItemComponent]s nested under a group. + */ + private fun addMissingResponseItems( + questionnaireItems: List, + responseItems: MutableList, + ) { + // To associate the linkId to QuestionnaireResponseItemComponent, do not use associateBy(). + // Instead, use groupBy(). + // This is because a questionnaire response may have multiple + // QuestionnaireResponseItemComponents with the same linkId. + val responseItemMap = responseItems.groupBy { it.linkId } + + // Clear the response item list, and then add the missing and existing response items back to + // the list + responseItems.clear() + + questionnaireItems.forEach { + if (responseItemMap[it.linkId].isNullOrEmpty()) { + responseItems.add(it.createQuestionnaireResponseItem()) + } else { + if (it.type == Questionnaire.QuestionnaireItemType.GROUP && !it.repeats) { + addMissingResponseItems( + questionnaireItems = it.item, + responseItems = responseItemMap[it.linkId]!!.single().item, + ) + } + responseItems.addAll(responseItemMap[it.linkId]!!) + } + } + } /** * Returns current [QuestionnaireResponse] captured by the UI which includes answers of enabled * questions. 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 2861a36614..e05243d31c 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 @@ -506,7 +506,7 @@ class QuestionnaireViewModelTest { } @Test - fun `should throw exception for non-matching question linkIds`() { + fun `should remove response item of non-matching question linkIds`() { val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -533,62 +533,21 @@ class QuestionnaireViewModelTest { ) } - val errorMessage = - assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } - .localizedMessage - - assertThat(errorMessage) - .isEqualTo("Missing questionnaire item for questionnaire response item a-different-link-id") - } - - @Test - fun `should throw an exception for extra questionnaire response items`() { - val questionnaire = - Questionnaire().apply { - id = "a-questionnaire" - addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "a-link-id" - text = "Basic question" - type = Questionnaire.QuestionnaireItemType.BOOLEAN - } - ) - } - val questionnaireResponse = + val expectedQuestionnaireResponse = QuestionnaireResponse().apply { id = "a-questionnaire-response" addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = BooleanType(true) - } - ) - } - ) - addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { - linkId = "a-different-link-id" - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = BooleanType(true) - } - ) + text = "Basic question" } ) } - val errorMessage = - assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } - .localizedMessage - - assertThat(errorMessage) - .isEqualTo("Missing questionnaire item for questionnaire response item a-different-link-id") + assertResourceEquals( + createQuestionnaireViewModel(questionnaire, questionnaireResponse).getQuestionnaireResponse(), + expectedQuestionnaireResponse + ) } @Test @@ -947,6 +906,202 @@ class QuestionnaireViewModelTest { createQuestionnaireViewModel(questionnaire, questionnaireResponse) } + @Test + fun `add empty nested QuestionnaireResponseItemComponent to the group if questions are not answered`() { + val questionnaireString = + """ + { + "resourceType": "Questionnaire", + "id": "client-registration-sample", + "item": [ + { + "linkId": "1", + "type": "group", + "item": [ + { + "linkId": "1.1", + "text": "First Nested Item", + "type": "boolean" + }, + { + "linkId": "1.2", + "text": "Second Nested Item", + "type": "boolean" + } + ] + } + ] + } + """.trimIndent() + + val questionnaireResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "1" + } + ] + } + """.trimIndent() + + val expectedResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "1", + "item": [ + { + "linkId": "1.1", + "text": "First Nested Item" + }, + { + "linkId": "1.2", + "text": "Second Nested Item" + } + ] + } + ] + } + """.trimIndent() + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString) + state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString) + val viewModel = QuestionnaireViewModel(context, state) + val value = viewModel.getQuestionnaireResponse() + val expectedResponse = + printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) + as QuestionnaireResponse + + assertResourceEquals(value, expectedResponse) + } + + @Test + fun `maintain an order while adding empty QuestionnaireResponseItemComponent to the response items`() { + val questionnaireString = + """ + { + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "1", + "type": "group", + "text": "Repeated Group", + "repeats": true, + "item": [ + { + "linkId": "1-1", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "yyyy-mm-dd" + } + ] + } + ] + }, + { + "linkId": "2", + "text": "Is this the first visit?", + "type": "boolean" + } + ] + } + """.trimIndent() + + val questionnaireResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "answer": [ + { + "valueDate": "2023-06-14" + } + ] + } + ] + }, + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "answer": [ + { + "valueDate": "2023-06-13" + } + ] + } + ] + } + ] + } + """.trimIndent() + + val expectedResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "answer": [ + { + "valueDate": "2023-06-14" + } + ] + } + ] + }, + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "answer": [ + { + "valueDate": "2023-06-13" + } + ] + } + ] + }, + { + "linkId": "2", + "text": "Is this the first visit?" + } + ] + } + """.trimIndent() + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString) + state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString) + val viewModel = QuestionnaireViewModel(context, state) + val value = viewModel.getQuestionnaireResponse() + val expectedResponse = + printer.parseResource(QuestionnaireResponse::class.java, expectedResponseString) + as QuestionnaireResponse + + assertResourceEquals(value, expectedResponse) + } + // ==================================================================== // // // // Questionnaire State Flow //