diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 6c22b144cb..d01364555c 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -122,6 +122,34 @@ internal fun StringType.toIdType(): IdType { return IdType(value) } +/** + * Checks if two Coding objects match. + * + * The matching logic is progressive: + * 1. Always matches on the [code]. + * 2. Matches on [system] if the both coding has a system. + * 3. Matches on [version] if the both coding has a version. + */ +internal fun Coding.matches(other: Coding): Boolean { + // Always match on code + if (this.code != other.code) { + return false + } + + // If system exists in both, it must match + if (other.hasSystem() && this.hasSystem() && this.system != other.system) { + return false + } + + // If version exists in both, it must match + if (other.hasVersion() && this.hasVersion() && this.version != other.version) { + return false + } + + // All conditions met + return true +} + internal fun StringType.localizedTextAnnotatedString(): AnnotatedString? { return this.getLocalizedText()?.toAnnotatedString() } 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 710c46a857..b5986ac611 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 @@ -16,12 +16,14 @@ package com.google.android.fhir.datacapture.mapping +import com.google.android.fhir.datacapture.extensions.copyNestedItemsToChildlessAnswers 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.matches import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts +import com.google.android.fhir.datacapture.extensions.shouldHaveNestedItemsUnderAnswers import com.google.android.fhir.datacapture.extensions.targetStructureMap import com.google.android.fhir.datacapture.extensions.toCodeType import com.google.android.fhir.datacapture.extensions.toCoding @@ -228,21 +230,30 @@ object ResourceMapper { launchContexts, questionnaire.questionnaireLaunchContexts ?: listOf(), ) - populateInitialValues(questionnaire.item, filteredLaunchContexts) return QuestionnaireResponse().apply { - item = questionnaire.item.map { it.createQuestionnaireResponseItem() } + item = + createPopulatedResponseItems(questionnaire.item, filteredLaunchContexts).toMutableList() } } - private suspend fun populateInitialValues( + private suspend fun createPopulatedResponseItems( questionnaireItems: List, launchContexts: Map, - ) { - questionnaireItems.forEach { populateInitialValue(it, launchContexts) } + ): List { + return questionnaireItems.map { it.createPopulatedResponseItem(launchContexts) } } - private suspend fun populateInitialValue( + private suspend fun Questionnaire.QuestionnaireItemComponent.createPopulatedResponseItem( + launchContexts: Map, + ): QuestionnaireResponse.QuestionnaireResponseItemComponent { + val questionnaireResponseItem = createQuestionnaireResponseItem() + populateQuestionnaireResponseItem(this, questionnaireResponseItem, launchContexts) + return questionnaireResponseItem + } + + private suspend fun populateQuestionnaireResponseItem( questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, launchContexts: Map, ) { check(questionnaireItem.initial.isEmpty() || questionnaireItem.initialExpression == null) { @@ -288,23 +299,61 @@ object ResourceMapper { * 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 = + val answers = + if (questionnaireItem.answerOption.isEmpty()) { evaluatedExpressionResult.map { - Questionnaire.QuestionnaireItemInitialComponent() - .setValue( - it, - ) + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = it } + } + } else { + questionnaireItem.answerOption.mapNotNull { answerOption -> + val optionValue = answerOption.value ?: return@mapNotNull null + if ( + evaluatedExpressionResult.any { evaluatedValue -> + if (optionValue is Coding && evaluatedValue is Coding) { + optionValue.matches(evaluatedValue) + } else { + optionValue.equalsDeep(evaluatedValue) + } + } + ) { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = optionValue + } + } else { + null + } } + } + questionnaireResponseItem.answer = answers.toMutableList() + if ( + questionnaireItem.shouldHaveNestedItemsUnderAnswers && + questionnaireResponseItem.answer.isNotEmpty() + ) { + questionnaireResponseItem.copyNestedItemsToChildlessAnswers(questionnaireItem) } } - populateInitialValues(questionnaireItem.item, launchContexts) + if (questionnaireItem.shouldHaveNestedItemsUnderAnswers) { + questionnaireResponseItem.answer.orEmpty().forEach { answerComponent -> + questionnaireItem.item.zipByLinkId(answerComponent.item.orEmpty()) { + childQuestionnaireItem, + childResponseItem, + -> + populateQuestionnaireResponseItem( + childQuestionnaireItem, + childResponseItem, + launchContexts, + ) + } + } + } else { + questionnaireItem.item.zipByLinkId(questionnaireResponseItem.item.orEmpty()) { + childQuestionnaireItem, + childResponseItem, + -> + populateQuestionnaireResponseItem(childQuestionnaireItem, childResponseItem, launchContexts) + } + } } /** diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt index 3f0e7b28cc..4a3d17f865 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.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. @@ -280,4 +280,92 @@ class MoreTypesTest { val quantity = Quantity(20L) assertThat(quantity.getValueAsString(context)).isEqualTo("20") } + + @Test + fun codingMatches_sameSystemAndCode_shouldReturnTrue() { + val left = Coding("system", "code", "display") + val right = Coding("system", "code", "display") + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_differentDisplays_shouldReturnTrue() { + val left = Coding("system", "code", "display") + val right = Coding("system", "code", "other display") + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_differentCodes_shouldReturnFalse() { + val left = Coding("system", "code", "display") + val right = Coding("system", "other-code", "display") + + assertThat(left.matches(right)).isFalse() + } + + @Test + fun codingMatches_differentSystems_shouldReturnFalse() { + val left = Coding("system", "code", "display") + val right = Coding("other-system", "code", "display") + + assertThat(left.matches(right)).isFalse() + } + + @Test + fun codingMatches_sameVersion_shouldReturnTrue() { + val left = Coding("system", "code", "display").apply { version = "1" } + val right = Coding("system", "code", "display").apply { version = "1" } + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_differentVersions_shouldReturnFalse() { + val left = Coding("system", "code", "display").apply { version = "1" } + val right = Coding("system", "code", "display").apply { version = "2" } + + assertThat(left.matches(right)).isFalse() + } + + @Test + fun codingMatches_missingSystemOnOneSide_shouldReturnTrue() { + val left = Coding(null, "code", "display") + val right = Coding("system", "code", "display") + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_missingVersionOnOneSide_shouldReturnTrue() { + val left = Coding("system", "code", "display").apply { version = "1" } + val right = Coding("system", "code", "display") + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_missingDisplayOnOneSide_shouldReturnTrue() { + val left = Coding("system", "code", null) + val right = Coding("system", "code", "display") + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_bothMissingSystem_shouldReturnTrue() { + val left = Coding(null, "code", "display") + val right = Coding(null, "code", "display") + + assertThat(left.matches(right)).isTrue() + } + + @Test + fun codingMatches_bothMissingDisplay_shouldReturnTrue() { + val left = Coding("system", "code", null) + val right = Coding("system", "code", null) + + assertThat(left.matches(right)).isTrue() + } } 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 a25e1e1817..4da7df1997 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 @@ -2063,6 +2063,199 @@ class ResourceMapperTest { .isEqualTo(patientId) } + @Test + fun `populate() should tolerate empty expression results for questions with nested items`() = + 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-contact" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patient.contact.given" + }, + ), + ) + item = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "patient-contact-type" + type = Questionnaire.QuestionnaireItemType.STRING + }, + ) + }, + ) + + val questionnaireResponse = + ResourceMapper.populate(questionnaire, mapOf("patient" to Patient())) + + val responseItem = questionnaireResponse.item.single() + assertThat(responseItem.answer?.isEmpty() ?: true).isTrue() + } + + @Test + fun `populate() should populate questions nested under non-repeated groups`() = 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 = "demographics" + type = Questionnaire.QuestionnaireItemType.GROUP + item = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "patient-name" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patient.name.given.first()" + }, + ), + ) + }, + ) + }, + ) + + val patient = + Patient().apply { + name = + listOf( + HumanName().apply { + family = "User" + addGiven("Test") + }, + ) + } + + val questionnaireResponse = ResourceMapper.populate(questionnaire, mapOf("patient" to patient)) + + val groupResponse = questionnaireResponse.item.single() + val nameResponse = groupResponse.item.single() + + assertThat((nameResponse.answer.single().value as StringType).value).isEqualTo("Test") + } + + @Test + fun `populate() should populate nested items for each repeating answer`() = 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 = "favorite-colors" + type = Questionnaire.QuestionnaireItemType.CHOICE + repeats = true + answerOption = + listOf( + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + system = "http://example.org/colors" + code = "red" + display = "Red" + }, + ), + Questionnaire.QuestionnaireItemAnswerOptionComponent( + Coding().apply { + system = "http://example.org/colors" + code = "blue" + display = "Blue" + }, + ), + ) + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patient.extension('http://example.org/favorite-color').value" + }, + ), + ) + item = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "color-source" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patient.id" + }, + ), + ) + }, + ) + }, + ) + + val patient = + Patient().apply { + id = "patient-1" + addExtension( + "http://example.org/favorite-color", + Coding("http://example.org/colors", "red", "Red"), + ) + addExtension( + "http://example.org/favorite-color", + Coding("http://example.org/colors", "blue", "Blue"), + ) + } + + val questionnaireResponse = ResourceMapper.populate(questionnaire, mapOf("patient" to patient)) + + val answerComponents = questionnaireResponse.item.single().answer + + assertThat(answerComponents.map { (it.value as Coding).code }).containsExactly("red", "blue") + answerComponents.forEach { answerComponent -> + val nestedResponse = answerComponent.item.single() + assertThat((nestedResponse.answer.single().value as StringType).value).isEqualTo("patient-1") + } + } + private fun createPatientResource(): Patient { return Patient().apply { active = true @@ -3144,6 +3337,212 @@ class ResourceMapperTest { } } + @Test + fun `populate() should select coding answerOption when only display differs`() = runBlocking { + val matchingAnswerOption = + Coding().apply { + system = "http://loinc.org" + code = "12345-6" + version = "1.0" + display = "Answer option display" + } + val questionnaire = + createObservationChoiceQuestionnaire( + matchingAnswerOption, + Coding().apply { + system = "http://loinc.org" + code = "65432-1" + display = "Fallback display" + }, + ) + + val observationCoding = + Coding().apply { + system = matchingAnswerOption.system + code = matchingAnswerOption.code + version = matchingAnswerOption.version + display = "Observation display" + } + + val questionnaireResponse = + ResourceMapper.populate( + questionnaire, + mapOf("observation" to observationWithCoding(observationCoding)), + ) + + val responseItem = questionnaireResponse.item.single() + assertThat(responseItem.hasAnswer()).isTrue() + val answerCoding = responseItem.answer.single().value as Coding + assertThat(answerCoding.code).isEqualTo(matchingAnswerOption.code) + assertThat(answerCoding.system).isEqualTo(matchingAnswerOption.system) + assertThat(answerCoding.version).isEqualTo(matchingAnswerOption.version) + assertThat(answerCoding.display).isEqualTo(matchingAnswerOption.display) + } + + @Test + fun `populate() should not mutate questionnaire when populating answers`() = 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-first-name" + type = Questionnaire.QuestionnaireItemType.STRING + extension = + listOf( + Extension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%patient.name.first().given.first()" + }, + ), + ) + }, + ) + + val patient = + Patient().apply { + name = listOf(HumanName().apply { given = mutableListOf(StringType("Jing")) }) + } + + val questionnaireResponse = ResourceMapper.populate(questionnaire, mapOf("patient" to patient)) + + val question = questionnaire.item.single() + assertThat(question.initial).isEmpty() + assertThat(question.answerOption.none { it.initialSelected }).isTrue() + + val answer = questionnaireResponse.item.single().answer.single().value as StringType + assertThat(answer.value).isEqualTo("Jing") + } + + @Test + fun `populate() should not select coding answerOption when codes differ`() = runBlocking { + val matchingAnswerOption = + Coding().apply { + system = "http://loinc.org" + code = "12345-6" + version = "1.0" + display = "Answer option display" + } + val questionnaire = + createObservationChoiceQuestionnaire( + matchingAnswerOption, + Coding().apply { + system = "http://loinc.org" + code = "65432-1" + display = "Fallback display" + }, + ) + + val questionnaireResponse = + ResourceMapper.populate( + questionnaire, + mapOf( + "observation" to + observationWithCoding( + Coding().apply { + system = matchingAnswerOption.system + code = "different-code" + version = matchingAnswerOption.version + display = "Observation display" + }, + ), + ), + ) + + val question = questionnaire.item.single() + assertThat(question.answerOption.none { it.initialSelected }).isTrue() + assertThat(questionnaireResponse.item.single().hasAnswer()).isFalse() + } + + @Test + fun `populate() should not select coding answerOption when systems differ`() = runBlocking { + val matchingAnswerOption = + Coding().apply { + system = "http://loinc.org" + code = "12345-6" + version = "1.0" + display = "Answer option display" + } + val questionnaire = + createObservationChoiceQuestionnaire( + matchingAnswerOption, + Coding().apply { + system = "http://loinc.org" + code = "65432-1" + display = "Fallback display" + }, + ) + + val questionnaireResponse = + ResourceMapper.populate( + questionnaire, + mapOf( + "observation" to + observationWithCoding( + Coding().apply { + system = "http://snomed.info/sct" + code = matchingAnswerOption.code + version = matchingAnswerOption.version + display = "Observation display" + }, + ), + ), + ) + + assertThat(questionnaireResponse.item.single().hasAnswer()).isFalse() + } + + @Test + fun `populate() should not select coding answerOption when versions differ`() = runBlocking { + val matchingAnswerOption = + Coding().apply { + system = "http://loinc.org" + code = "12345-6" + version = "1.0" + display = "Answer option display" + } + val questionnaire = + createObservationChoiceQuestionnaire( + matchingAnswerOption, + Coding().apply { + system = "http://loinc.org" + code = "65432-1" + display = "Fallback display" + }, + ) + + val questionnaireResponse = + ResourceMapper.populate( + questionnaire, + mapOf( + "observation" to + observationWithCoding( + Coding().apply { + system = matchingAnswerOption.system + code = matchingAnswerOption.code + version = "2.0" + display = "Observation display" + }, + ), + ), + ) + + val question = questionnaire.item.single() + assertThat(question.answerOption.none { it.initialSelected }).isTrue() + assertThat(questionnaireResponse.item.single().hasAnswer()).isFalse() + } + @Test fun `populate() should select single answer for non repeating question with answerOption`() = runBlocking { @@ -3596,6 +3995,54 @@ class ResourceMapperTest { assertThat(patient.name.first().family).isEqualTo("John Doe") } + private fun createObservationChoiceQuestionnaire( + vararg answerOptions: Coding, + ): 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 = + answerOptions + .map { coding -> Questionnaire.QuestionnaireItemAnswerOptionComponent(coding) } + .toMutableList() + }, + ) + + private fun observationWithCoding(coding: Coding): Observation = + Observation().apply { + status = Observation.ObservationStatus.FINAL + value = CodeableConcept().apply { this.coding = mutableListOf(coding) } + } + private fun readFileFromResourcesAsString(filename: String) = readFileFromResources(filename).bufferedReader().use { it.readText() }