diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index f1f47495e9..781035258e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.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. @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.mapping import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension import com.google.android.fhir.datacapture.extensions.initialExpression +import com.google.android.fhir.datacapture.extensions.initialSelected import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts import com.google.android.fhir.datacapture.extensions.targetStructureMap @@ -248,22 +249,57 @@ object ResourceMapper { "QuestionnaireItem item is not allowed to have both initial.value and initial expression. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression." } + // Initial values can't be specified for groups or display items + check( + !(questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP || + questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY) || + (questionnaireItem.initial.isEmpty() && questionnaireItem.initialExpression == null), + ) { + "QuestionnaireItem item is not allowed to have initial value or initial expression for groups or display items. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression." + } + questionnaireItem.initialExpression ?.let { evaluateToBase( - questionnaireResponse = null, - questionnaireResponseItem = null, - expression = it.expression, - contextMap = launchContexts, - ) - .firstOrNull() + questionnaireResponse = null, + questionnaireResponseItem = null, + expression = it.expression, + contextMap = launchContexts, + ) } ?.let { - // Set initial value for the questionnaire item. Questionnaire items should not have both - // initial value and initial expression. - val value = it.asExpectedType(questionnaireItem.type) - questionnaireItem.initial = - mutableListOf(Questionnaire.QuestionnaireItemInitialComponent().setValue(value)) + // Set initial value for the questionnaire item. + if (it.isEmpty()) return@let + + // If questionnaireItem.repeats is false only first value is selected from initialExpression + // result set + val evaluatedExpressionResult = + if (questionnaireItem.repeats) { + it.map { it.asExpectedType(questionnaireItem.type) } + } else { + listOf(it.first().asExpectedType(questionnaireItem.type)) + } + + // For answer options, the initialSelected extension is used to highlight initial values. + // Note: If the initial expression evaluates to 1, 2, 3, 4, 5, but only 3 answer options (1, + // 2, 3) exist, + // then 4 and 5 will be ignored. These values are not added as additional options, nor would + // it make sense to do so. + // This behavior ensures the answer options remain consistent with the defined set. + if (questionnaireItem.answerOption.isNotEmpty()) { + questionnaireItem.answerOption.forEach { answerOption -> + answerOption.initialSelected = + evaluatedExpressionResult.any { answerOption.value.equalsDeep(it) } + } + } else { + questionnaireItem.initial = + evaluatedExpressionResult.map { + Questionnaire.QuestionnaireItemInitialComponent() + .setValue( + it, + ) + } + } } populateInitialValues(questionnaireItem.item, launchContexts) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt index 52d29f390e..a25e1e1817 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.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. @@ -41,7 +41,9 @@ import org.hl7.fhir.r4.elementmodel.Manager import org.hl7.fhir.r4.model.Address import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.ContactPoint import org.hl7.fhir.r4.model.DateType @@ -1955,12 +1957,14 @@ class ResourceMapperTest { listOf( Questionnaire.QuestionnaireItemAnswerOptionComponent( Coding().apply { + system = AdministrativeGender.MALE.system code = AdministrativeGender.MALE.toCode() display = AdministrativeGender.MALE.display }, ), Questionnaire.QuestionnaireItemAnswerOptionComponent( Coding().apply { + system = AdministrativeGender.FEMALE.system code = AdministrativeGender.FEMALE.toCode() display = AdministrativeGender.FEMALE.display }, @@ -2011,12 +2015,14 @@ class ResourceMapperTest { Coding().apply { code = AdministrativeGender.MALE.toCode() display = AdministrativeGender.MALE.display + system = AdministrativeGender.MALE.system }, ), Questionnaire.QuestionnaireItemAnswerOptionComponent( Coding().apply { code = AdministrativeGender.FEMALE.toCode() display = AdministrativeGender.FEMALE.display + system = AdministrativeGender.MALE.system }, ), ) @@ -2973,6 +2979,490 @@ class ResourceMapperTest { ) } + @Test + fun `populate() should fail with IllegalStateException when QuestionnaireItem of group item has a initial value`(): + Unit = runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), + Extension("type", CodeType("Patient")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "patient-gender-group-initial-expression" + type = Questionnaire.QuestionnaireItemType.GROUP + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%father.gender" + }, + ), + ) + }, + ) + + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } + val errorMessage = + assertFailsWith { + ResourceMapper.populate(questionnaire, mapOf("father" to patient)) + } + .localizedMessage + assertThat(errorMessage) + .isEqualTo( + "QuestionnaireItem item is not allowed to have initial value or initial expression for groups or display items. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression.", + ) + } + + @Test + fun `populate() should fail with IllegalStateException when QuestionnaireItem of display item has a initial value`(): + Unit = runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), + Extension("type", CodeType("Patient")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "patient-gender-display-initial-expression" + type = Questionnaire.QuestionnaireItemType.DISPLAY + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%father.gender" + }, + ), + ) + }, + ) + + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } + val errorMessage = + assertFailsWith { + ResourceMapper.populate(questionnaire, mapOf("father" to patient)) + } + .localizedMessage + assertThat(errorMessage) + .isEqualTo( + "QuestionnaireItem item is not allowed to have initial value or initial expression for groups or display items. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression.", + ) + } + + @Test + fun `populate() should select answerOption of type coding(without system) if initialExpression result matches its coding(without system)`() = + runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension( + "name", + Coding( + CODE_SYSTEM_LAUNCH_CONTEXT, + "observation", + "Test Observation", + ), + ), + Extension("type", CodeType("Observation")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "observation-choice" + type = Questionnaire.QuestionnaireItemType.CHOICE + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%observation.value.coding" + }, + ), + ) + answerOption = + listOf( + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + code = "correct-code-val" + display = "correct-display-val" + }, + ), + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + code = "wrong-code-val" + display = "wrong-display-val" + }, + ), + ) + }, + ) + + val observation = + Observation().apply { + value = + CodeableConcept().apply { + coding = + mutableListOf( + Coding().apply { + code = "correct-code-val" + display = "correct-display-val" + }, + ) + } + } + + val questionnaireResponse = + ResourceMapper.populate(questionnaire, mapOf("observation" to observation)) + + with(questionnaireResponse.item[0].answer[0].value as Coding) { + assertThat(this.code).isEqualTo("correct-code-val") + assertThat(this.display).isEqualTo("correct-display-val") + } + } + + @Test + fun `populate() should select single answer for non repeating question with answerOption`() = + runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension( + "name", + Coding( + CODE_SYSTEM_LAUNCH_CONTEXT, + "observations", + "List of observations", + ), + ), + Extension("type", CodeType("Observation")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "observation-choice" + type = Questionnaire.QuestionnaireItemType.CHOICE + repeats = false + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%observations.entry.resource.value.coding" + }, + ), + ) + answerOption = + listOf( + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + code = "correct-code-val" + display = "correct-display-val" + }, + ), + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + code = "wrong-code-val" + display = "wrong-display-val" + }, + ), + ) + }, + ) + + val observation1 = + Observation().apply { + status = Observation.ObservationStatus.FINAL + value = + CodeableConcept().apply { + coding = + mutableListOf( + Coding().apply { + code = "correct-code-val" + display = "correct-display-val" + }, + ) + } + } + val observation2 = + Observation().apply { + status = Observation.ObservationStatus.FINAL + value = + CodeableConcept().apply { + coding = + mutableListOf( + Coding().apply { + code = "wrong-code-val" + display = "wrong-display-val" + }, + ) + } + } + val observationBundle = + Bundle().apply { + addEntry().resource = observation1 + addEntry().resource = observation2 + } + + val questionnaireResponse = + ResourceMapper.populate(questionnaire, mapOf("observations" to observationBundle)) + + assertThat((questionnaireResponse.item[0].answer[0].value as Coding).code) + .isEqualTo("correct-code-val") + assertThat((questionnaireResponse.item[0].answer[0].value as Coding).display) + .isEqualTo("correct-display-val") + } + + @Test + fun `populate() should select multiple answer for repeating question with answerOption`() = + runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension( + "name", + Coding( + CODE_SYSTEM_LAUNCH_CONTEXT, + "observations", + "List of observations", + ), + ), + Extension("type", CodeType("Observation")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "observation-choice" + type = Questionnaire.QuestionnaireItemType.CHOICE + repeats = true + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%observations.entry.resource.value.coding" + }, + ), + ) + answerOption = + listOf( + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + code = "correct-code-val" + display = "correct-display-val" + }, + ), + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + code = "correct2-code-val" + display = "correct2-display-val" + }, + ), + ) + }, + ) + + val observation1 = + Observation().apply { + status = Observation.ObservationStatus.FINAL + value = + CodeableConcept().apply { + coding = + mutableListOf( + Coding().apply { + code = "correct-code-val" + display = "correct-display-val" + }, + ) + } + } + val observation2 = + Observation().apply { + status = Observation.ObservationStatus.FINAL + value = + CodeableConcept().apply { + coding = + mutableListOf( + Coding().apply { + code = "correct2-code-val" + display = "correct2-display-val" + }, + ) + } + } + val observationBundle = + Bundle().apply { + addEntry().resource = observation1 + addEntry().resource = observation2 + } + + val questionnaireResponse = + ResourceMapper.populate(questionnaire, mapOf("observations" to observationBundle)) + + with(questionnaireResponse.item[0].answer[0].value as Coding) { + assertThat(this.code).isEqualTo("correct-code-val") + assertThat(this.display).isEqualTo("correct-display-val") + } + with(questionnaireResponse.item[0].answer[1].value as Coding) { + assertThat(this.code).isEqualTo("correct2-code-val") + assertThat(this.display).isEqualTo("correct2-display-val") + } + } + + @Test + fun `populate() should select a single initial answer for non repeating question without answerOption`() = + runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension( + "name", + Coding( + CODE_SYSTEM_LAUNCH_CONTEXT, + "patient", + "Patient", + ), + ), + Extension("type", CodeType("Patient")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "patient-name" + type = Questionnaire.QuestionnaireItemType.TEXT + repeats = false + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patient.name.given" + }, + ), + ) + }, + ) + + val patient = + Patient().apply { + addName( + HumanName().apply { addGiven("Parth") }, + ) + } + + val questionnaireResponse = + ResourceMapper.populate(questionnaire, mapOf("patient" to patient)) + + assertThat((questionnaireResponse.item[0].answer[0].valueStringType.valueAsString)) + .isEqualTo("Parth") + } + + @Test + fun `populate() should select a first single initial answer for non repeating question without answerOption and initialExpression evalutes to multiple values`() = + runBlocking { + val questionnaire = + Questionnaire() + .apply { + addExtension().apply { + url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT + extension = + listOf( + Extension( + "name", + Coding( + CODE_SYSTEM_LAUNCH_CONTEXT, + "patients", + "list of patients", + ), + ), + Extension("type", CodeType("Patient")), + ) + } + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "patient-name" + type = Questionnaire.QuestionnaireItemType.TEXT + repeats = false + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patients.entry.resource.name.given" + }, + ), + ) + }, + ) + + val patient1 = + Patient().apply { + addName( + HumanName().apply { addGiven("Parth") }, + ) + } + val patient2 = + Patient().apply { + addName( + HumanName().apply { addGiven("Jose") }, + ) + } + + val patientBundle = + Bundle().apply { + addEntry().resource = patient1 + addEntry().resource = patient2 + } + val questionnaireResponse = + ResourceMapper.populate(questionnaire, mapOf("patients" to patientBundle)) + + assertThat((questionnaireResponse.item[0].answer[0].valueStringType.valueAsString)) + .isEqualTo("Parth") + } + @Test fun `extract() definition based extraction should extract multiple values of a list field in a group`(): Unit = runBlocking {