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 c51bfb8413..0e0fa06457 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-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. @@ -121,6 +121,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 +} + /** Converts Coding to CodeType. */ internal fun Coding.toCodeType(): CodeType { return CodeType(code) 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..929a002c52 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 @@ -21,6 +21,7 @@ import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtensio 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.targetStructureMap import com.google.android.fhir.datacapture.extensions.toCodeType @@ -291,7 +292,13 @@ object ResourceMapper { if (questionnaireItem.answerOption.isNotEmpty()) { questionnaireItem.answerOption.forEach { answerOption -> answerOption.initialSelected = - evaluatedExpressionResult.any { answerOption.value.equalsDeep(it) } + evaluatedExpressionResult.any { evaluatedItem -> + if (answerOption.value is Coding && evaluatedItem is Coding) { + (answerOption.value as Coding).matches(evaluatedItem) + } else { + answerOption.value.equalsDeep(evaluatedItem) + } + } } } else { questionnaireItem.initial = 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..cf4bc0b29a 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 @@ -3144,6 +3144,175 @@ 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 question = questionnaire.item.single() + val selectedOption = + question.answerOption.first { (it.value as Coding).code == matchingAnswerOption.code } + + assertThat(selectedOption.initialSelected).isTrue() + assertThat(question.answerOption.count { it.initialSelected }).isEqualTo(1) + + 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 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" + }, + ), + ), + ) + + 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 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 +3765,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() }