From 859a1be77c726a5166977a8235927e6041bc0457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Wed, 8 Nov 2023 08:23:34 +0300 Subject: [PATCH] Evaluate cqf-calculatedValue expressions using ExpressionEvaluator to add support for variable expressions, launchContexts and %resource expressions --- .../datacapture/QuestionnaireViewModel.kt | 37 +- .../MoreQuestionnaireItemComponents.kt | 3 - .../fhir/datacapture/extensions/MoreTypes.kt | 20 +- .../AnswerExtensionConstraintValidator.kt | 5 +- .../validation/MaxValueValidator.kt | 11 +- .../validation/MinValueValidator.kt | 13 +- .../QuestionnaireResponseValidator.kt | 52 + .../views/QuestionnaireViewItem.kt | 11 +- .../factories/DatePickerViewHolderFactory.kt | 6 +- .../factories/SliderViewHolderFactory.kt | 16 +- .../datacapture/QuestionnaireViewModelTest.kt | 892 +++++++++++++++++- .../datacapture/extensions/MoreTypesTest.kt | 12 + .../validation/MaxLengthValidatorTest.kt | 12 +- .../validation/MaxValueValidatorTest.kt | 145 ++- .../validation/MinLengthValidatorTest.kt | 12 +- .../validation/MinValueValidatorTest.kt | 128 +-- .../QuestionnaireResponseItemValidatorTest.kt | 18 +- .../validation/RegexValidatorTest.kt | 12 +- 18 files changed, 1170 insertions(+), 235 deletions(-) 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 8c5d551cca..5d8e480214 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 @@ -27,6 +27,7 @@ import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.enablement.EnablementEvaluator import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer import com.google.android.fhir.datacapture.extensions.allItems @@ -68,12 +69,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Element +import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Type import timber.log.Timber internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) : @@ -579,6 +582,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) } + private fun resolveCqfCalculatedValueExpression( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + expression: Expression, + ): Type? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + + return expressionEvaluator + .evaluateExpression(questionnaireItem, questionnaireResponseItem, expression) + .singleOrNull() + ?.let { it as Type } + } + private fun removeDisabledAnswers( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, @@ -707,6 +725,23 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem) + // Evaluate cqf-calculatedValues + questionnaireItem.extension + .filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + .forEach { extension -> + val expression = + extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value as Expression + resolveCqfCalculatedValueExpression( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + ?.let { + it.apply { setExtension(extension.value.extension) } + extension.setValue(it) + } + } + // Determine the validation result, which will be displayed on the item itself val validationResult = if ( @@ -717,7 +752,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat QuestionnaireResponseItemValidator.validate( questionnaireItem, questionnaireResponseItem.answer, - this@QuestionnaireViewModel.getApplication(), + context = this@QuestionnaireViewModel.getApplication(), ) } else { NotValidated diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index 5ae1cd30b8..9981d805be 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt @@ -82,9 +82,6 @@ internal const val EXTENSION_CHOICE_ORIENTATION_URL = internal const val EXTENSION_CHOICE_COLUMN_URL: String = "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn" -internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = - "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" - internal const val EXTENSION_DISPLAY_CATEGORY_URL = "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory" 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 7df499a385..25dac5004f 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 @@ -97,10 +97,8 @@ private fun getDisplayString(type: Type, context: Context): String? = private fun getValueString(type: Type): String? = when (type) { - is DateType, - is DateTimeType, - is StringType, -> type.asStringValue() - is Quantity -> type.value.toString() + is StringType -> type.getLocalizedText() ?: type.valueAsString + is Quantity -> type.takeIf { it.hasValue() }?.value?.toString() else -> (type as? PrimitiveType<*>)?.valueAsString } @@ -125,15 +123,19 @@ internal fun Coding.toCodeType(): CodeType { } fun Type.valueOrCalculateValue(): Type { - return if (this.hasExtension()) { - this.extension - .firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } + return if (getValueString(this) != null) { + this + } else { + this.takeIf { hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + ?.extension + ?.firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } ?.let { extension -> val expression = (extension.value as Expression).expression fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type } } ?: this - } else { - this } } + +internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt index 46928b4435..9696908946 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt @@ -35,7 +35,10 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse internal open class AnswerExtensionConstraintValidator( val url: String, val predicate: - (Extension, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) -> Boolean, + ( + Extension, + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, + ) -> Boolean, val messageGenerator: (Extension, Context) -> String, ) : AnswerConstraintValidator { override fun validate( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt index ba42e702bf..70f9f2c0d2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt @@ -22,9 +22,7 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getValueAsString import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.Type internal const val MAX_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/maxValue" @@ -44,11 +42,4 @@ internal object MaxValueValidator : extension.value?.valueOrCalculateValue()?.getValueAsString(context), ) }, - ) { - - fun getMaxValue(questionnaireItemComponent: Questionnaire.QuestionnaireItemComponent): Type? { - return questionnaireItemComponent.extension - .firstOrNull { it.url == MAX_VALUE_EXTENSION_URL } - ?.let { it.value?.valueOrCalculateValue() } - } -} + ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt index ff44447a59..021975e503 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt @@ -22,9 +22,7 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getValueAsString import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.Type internal const val MIN_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minValue" @@ -44,13 +42,4 @@ internal object MinValueValidator : extension.value?.valueOrCalculateValue()?.getValueAsString(context), ) }, - ) { - - internal fun getMinValue( - questionnaireItemComponent: Questionnaire.QuestionnaireItemComponent, - ): Type? { - return questionnaireItemComponent.extension - .firstOrNull { it.url == MIN_VALUE_EXTENSION_URL } - ?.let { it.value?.valueOrCalculateValue() } - } -} + ) 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 3fb69c03c5..2a54060f62 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 @@ -18,9 +18,15 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL +import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.packRepeatedGroups +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator +import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.Type @@ -69,6 +75,13 @@ object QuestionnaireResponseValidator { } val linkIdToValidationResultMap = mutableMapOf>() + val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + launchContextMap, + ) validateQuestionnaireResponseItems( questionnaire.item, @@ -80,17 +93,33 @@ object QuestionnaireResponseValidator { questionnaireItemParentMap, launchContextMap, ), + expressionEvaluator, linkIdToValidationResultMap, ) return linkIdToValidationResultMap } + private fun ExpressionEvaluator.resolveCqfCalculatedValue( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + expression: Expression, + ): Type? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + + return evaluateExpression(questionnaireItem, questionnaireResponseItem, expression) + .singleOrNull() + ?.let { it as Type } + } + private fun validateQuestionnaireResponseItems( questionnaireItemList: List, questionnaireResponseItemList: List, context: Context, enablementEvaluator: EnablementEvaluator, + expressionEvaluator: ExpressionEvaluator, linkIdToValidationResultMap: MutableMap>, ): Map> { val questionnaireItemListIterator = questionnaireItemList.iterator() @@ -113,11 +142,31 @@ object QuestionnaireResponseValidator { ) if (enabled) { + // Evaluate cqf-calculatedValues + questionnaireItem.extension + .filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + .forEach { extension -> + val expression = + extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value + as Expression + expressionEvaluator + .resolveCqfCalculatedValue( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + ?.let { + it.apply { setExtension(extension.value.extension) } + extension.setValue(it) + } + } + validateQuestionnaireResponseItem( questionnaireItem, questionnaireResponseItem, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) } @@ -130,6 +179,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, context: Context, enablementEvaluator: EnablementEvaluator, + expressionEvaluator: ExpressionEvaluator, linkIdToValidationResultMap: MutableMap>, ): Map> { when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) { @@ -143,6 +193,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem.item, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) else -> { @@ -156,6 +207,7 @@ object QuestionnaireResponseValidator { it.item, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index f12af25a1a..4d75941954 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -23,6 +23,8 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.toSpanned +import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL +import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult @@ -52,12 +54,9 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse * @param validationResult the [ValidationResult] of the answer(s) against the `questionnaireItem` * @param answersChangedCallback the callback to notify the view model that the answers have been * changed for the [QuestionnaireResponse.QuestionnaireResponseItemComponent] - * @param resolveAnswerValueSet the callback to resolve the answer value set and return the answer - * @param resolveAnswerExpression the callback to resolve answer options when answer-expression - * extension exists options + * @param enabledAnswerOptions the enabled answer options in [questionnaireItem]] * @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse]. * @param enabledDisplayItems the enabled display items in the given [questionnaireItem] - * @param showOptionalText the optional text is being added to the end of the question text * @param questionViewTextConfiguration configuration to show asterisk, required and optional text * in the header view. */ @@ -94,6 +93,10 @@ data class QuestionnaireViewItem( val answers: List = questionnaireResponseItem.answer.map { it.copy() } + val minValue by lazy { questionnaireItem.getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value } + + val maxValue by lazy { questionnaireItem.getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value } + /** Updates the answers. This will override any existing answers and removes the draft answer. */ fun setAnswer( vararg questionnaireResponseItemAnswerComponent: diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index abd1b20836..867df9fd02 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt @@ -32,8 +32,6 @@ import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.parseDate import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.MaxValueValidator.getMaxValue -import com.google.android.fhir.datacapture.validation.MinValueValidator.getMinValue import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem @@ -163,8 +161,8 @@ internal object DatePickerViewHolderFactory : } private fun getCalenderConstraint(): CalendarConstraints { - val min = (getMinValue(questionnaireViewItem.questionnaireItem) as? DateType)?.value?.time - val max = (getMaxValue(questionnaireViewItem.questionnaireItem) as? DateType)?.value?.time + val min = (questionnaireViewItem.minValue as? DateType)?.value?.time + val max = (questionnaireViewItem.maxValue as? DateType)?.value?.time if (min != null && max != null && min > max) { throw IllegalArgumentException("minValue cannot be greater than maxValue") diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt index 8c92945706..7a4b8a241f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt @@ -21,8 +21,6 @@ import android.widget.TextView import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.sliderStepValue import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.MaxValueValidator -import com.google.android.fhir.datacapture.validation.MinValueValidator import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult @@ -30,8 +28,8 @@ import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.slider.Slider import org.hl7.fhir.r4.model.IntegerType -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal object SliderViewHolderFactory : QuestionnaireItemViewHolderFactory(R.layout.slider_view) { override fun getQuestionnaireItemViewHolderDelegate(): QuestionnaireItemViewHolderDelegate = @@ -52,8 +50,8 @@ internal object SliderViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l header.bind(questionnaireViewItem) header.showRequiredOrOptionalTextInHeaderView(questionnaireViewItem) val answer = questionnaireViewItem.answers.singleOrNull() - val minValue = getMinValue(questionnaireViewItem.questionnaireItem) - val maxValue = getMaxValue(questionnaireViewItem.questionnaireItem) + val minValue = getMinValue(questionnaireViewItem.minValue) + val maxValue = getMaxValue(questionnaireViewItem.maxValue) if (minValue >= maxValue) { throw IllegalStateException("minValue $minValue must be smaller than maxValue $maxValue") } @@ -98,15 +96,15 @@ private const val SLIDER_DEFAULT_STEP_SIZE = 1 private const val SLIDER_DEFAULT_VALUE_FROM = 0.0F private const val SLIDER_DEFAULT_VALUE_TO = 100.0F -private fun getMinValue(questionnaireItem: Questionnaire.QuestionnaireItemComponent) = - when (val minValue = MinValueValidator.getMinValue(questionnaireItem)) { +private fun getMinValue(minValue: Type?) = + when (minValue) { is IntegerType -> minValue.value.toFloat() null -> SLIDER_DEFAULT_VALUE_FROM else -> throw IllegalArgumentException("Cannot support data type: ${minValue.fhirType()}}") } -private fun getMaxValue(questionnaireItem: Questionnaire.QuestionnaireItemComponent) = - when (val maxValue = MaxValueValidator.getMaxValue(questionnaireItem)) { +private fun getMaxValue(maxValue: Type?) = + when (maxValue) { is IntegerType -> maxValue.value.toFloat() null -> SLIDER_DEFAULT_VALUE_TO else -> throw IllegalArgumentException("Cannot support data type: ${maxValue.fhirType()}}") 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 234441c622..630be50ef7 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 @@ -36,6 +36,7 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TO import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_CALCULATED_EXPRESSION_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_EXPRESSION_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_SYSTEM @@ -45,6 +46,8 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ENTRY_MODE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_LAUNCH_CONTEXT +import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.asStringValue @@ -53,9 +56,14 @@ import com.google.android.fhir.datacapture.extensions.getNestedQuestionnaireResp import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.android.fhir.datacapture.validation.Invalid +import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL +import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId import java.util.Calendar import java.util.Date import java.util.UUID @@ -4376,13 +4384,13 @@ class QuestionnaireViewModelTest { extension = listOf( Extension( - "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext", + EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT, ) .apply { addExtension( "name", Coding( - "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext", + EXTENSION_LAUNCH_CONTEXT, "patient", "Patient", ), @@ -5774,6 +5782,886 @@ class QuestionnaireViewModelTest { .isEqualTo("a-birthdate and a-age-years have cyclic dependency in expression based extension") } + // ==================================================================== // + // // + // cqf-calculatedValue Expression for minValue/maxValue Extension // + // // + // ==================================================================== // + + @Test + fun `should return calculated value for minValue extension with cqf-calculatedValue`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should replace value with evaluated cql-calculatedValue for minValue extension`() = runTest { + val lastLocalDate = LocalDate.now().minusMonths(1) + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + value = + Date.from( + lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isNotEqualTo(lastLocalDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue for minValue extension`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Minimum value allowed is:${LocalDate.now()}"))) + } + } + } + + @Test + fun `should return calculated value for maxValue extension with cqf-calculatedValue`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((maxValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should replace value with evaluated cql-calculatedValue for maxValue extension`() = runTest { + val lastLocalDate = LocalDate.now().minusMonths(1) + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + value = + Date.from( + lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((maxValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isNotEqualTo(lastLocalDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue for maxValue extension`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val tomorrow = LocalDate.now().plusDays(1) + value = + Date.from(tomorrow.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Maximum value allowed is:${LocalDate.now()}"))) + } + } + } + + @Test + fun `should evaluate cqf-calculatedValue with expression dependent on other question`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select minimum date" + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = + "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { assertThat((minValue as? DateType)?.valueAsString).isNull() } + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231014") + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo("2023-10-14") + val questionnaireItem = + viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo("2023-10-14") + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue expression dependent on other question for maxValue extension`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select minimum date" + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = + "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231014") + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val tomorrow = LocalDate.now().plusDays(1) + value = + Date.from(tomorrow.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Maximum value allowed is:2023-10-14"))) + } + } + } + + @Test + fun `should evaluate cqf-calculatedValue with expression dependent on a variable expression`() = + runTest { + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_VARIABLE_URL).apply { + setValue( + Expression().apply { + name = "dateToday" + expression = "today()" + language = "text/fhirpath" + }, + ) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%dateToday" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single() + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = + viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should evaluate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("type", CodeType("Patient")) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%patient.birthDate" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = + mapOf("patient" to printer.encodeResourceToString(patient0)) + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single() + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(testDate.toString()) + val questionnaireItem = + viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(testDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue with expression dependent on x-fhir-query launchContext for minValue extension`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("type", CodeType("Patient")) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%patient.birthDate" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = + mapOf("patient" to printer.encodeResourceToString(patient0)) + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + value = patient0.birthDate + add(Calendar.MONTH, -1) + } + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Minimum value allowed is:$testDate"))) + } + } + } + + @Test + fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue with expression dependent on other question`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select minimum date" + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = + "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231108") + }, + ) + }, + ) + + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("b")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results.values).isNotEmpty() + assertThat(results["a"]?.single()).isInstanceOf(Valid::class.java) + assertThat(results["b"]?.single()) + .isEqualTo(Invalid(listOf("Minimum value allowed is:2023-11-08"))) + } + } + + @Test + fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue with variable expression`() = + runTest { + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_VARIABLE_URL).apply { + setValue( + Expression().apply { + name = "dateToday" + expression = "today()" + language = "text/fhirpath" + }, + ) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%dateToday" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results["a"]?.single()) + .isEqualTo(Invalid(listOf("Minimum value allowed is:${LocalDate.now()}"))) + } + } + + @Test + fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue expression dependent on x-fhir-query launchContext`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("type", CodeType("Patient")) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%patient.birthDate" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = + mapOf("patient" to printer.encodeResourceToString(patient0)) + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20001010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results["a"]?.single()) + .isEqualTo(Invalid(listOf("Minimum value allowed is:$testDate"))) + } + } + // ==================================================================== // // // // Display Category // 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 533acf8488..fdb6decd60 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 @@ -261,6 +261,18 @@ class MoreTypesTest { assertThat(reference.identifierString(context)).isEqualTo("fakeReference") } + @Test + fun `getValueAsString should return 'not answered' for an empty Quantity`() { + val quantity = Quantity() + assertThat(quantity.getValueAsString(context)).isEqualTo("Not Answered") + } + + @Test + fun `getValueAsString should return correct value for a Quantity`() { + val quantity = Quantity(20L) + assertThat(quantity.getValueAsString(context)).isEqualTo("20") + } + @Test fun `should return calculated value for cqf expression`() { val today = LocalDate.now().toString() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt index 73c49047e6..d134264027 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt @@ -145,7 +145,11 @@ class MaxLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = - MaxLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MaxLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) @@ -159,7 +163,11 @@ class MaxLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = - MaxLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MaxLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt index 9f3d738c4a..e5cce65740 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt @@ -21,8 +21,9 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat -import java.text.SimpleDateFormat import java.time.LocalDate +import java.time.ZoneId +import java.util.Date import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension @@ -91,85 +92,75 @@ class MaxValueValidatorTest { } @Test - fun `should return maxValue date`() { - val dateType = DateType(SimpleDateFormat("yyyy-MM-dd").parse("2023-06-01")) - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue(dateType) - }, - ) - }, - ) - - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.value) - .isEqualTo(dateType.value) - } + fun `should return invalid result with correct max allowed value if contains only cqf-calculatedValue`() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today() - 7 'days'" + language = "text/fhirpath" + }, + ), + ) + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } - @Test - fun `should return today's date when expression evaluates to today`() { - val today = LocalDate.now().toString() - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - }, - ) - }, - ) - }, - ) - - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(today) + val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage) + .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") } @Test - fun `should return future's date when expression evaluates`() { - val fiveDaysAhead = LocalDate.now().plusDays(5).toString() - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today() + 5 'days' " - }, - ), - ) - }, - ) - }, - ) - }, - ) - - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(fiveDaysAhead) + fun `should return invalid result with correct max allowed value if contains both value and cqf-calculatedValue`() { + val tenDaysAgo = LocalDate.now().minusDays(10) + + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + value = + Date.from(tenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today() - 7 'days'" + language = "text/fhirpath" + }, + ), + ) + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } + + val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage).isEqualTo("Maximum value allowed is:$tenDaysAgo") } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt index 3c14cee33b..6f7e452450 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt @@ -154,7 +154,11 @@ class MinLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = - MinLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MinLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -165,7 +169,11 @@ class MinLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = - MinLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MinLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt index b12ecbf2af..c99d4fb0f9 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt @@ -22,16 +22,15 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat -import java.text.SimpleDateFormat import java.time.LocalDate -import java.util.Calendar +import java.time.ZoneId import java.util.Date -import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.junit.Before import org.junit.Test @@ -99,7 +98,7 @@ class MinValueValidatorTest { } @Test - fun `should return invalid result when entered value is less than minValue for cqf calculated expression`() { + fun `should return valid result when entered value is greater than minValue for cqf calculated expression`() { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -122,19 +121,8 @@ class MinValueValidatorTest { }, ) } - val answerDate = - DateType( - SimpleDateFormat("yyyy-MM-dd") - .parse( - (DateTimeType.today() - .apply { - add(Calendar.YEAR, -1) - add(Calendar.DAY_OF_MONTH, -1) - } - .valueAsString), - ), - ) - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = answerDate } + + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } val validationResult = MinValueValidator.validate( @@ -142,98 +130,50 @@ class MinValueValidatorTest { answer, InstrumentationRegistry.getInstrumentation().context, ) - val expectedDateRange = - (MinValueValidator.getMinValue(questionnaireItem) as? DateType)?.valueAsString - assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage) - .isEqualTo("Minimum value allowed is:$expectedDateRange") + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @Test - fun `should return valid result when entered value is greater than minValue for cqf calculated expression`() { + fun `should return invalid result with correct min allowed value if contains both value and cqf-calculatedValue`() { + val threeDaysAgo = LocalDate.now().minusDays(3) + val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( Extension().apply { - url = MIN_VALUE_EXTENSION_URL + this.url = MIN_VALUE_EXTENSION_URL this.setValue( DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today() - 1 'days'" - }, - ), - ) + value = + Date.from(threeDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today() - 7 'days'" + language = "text/fhirpath" + }, + ), + ) }, ) }, ) } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val fiveDaysAgo = LocalDate.now().minusDays(5) + value = Date.from(fiveDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + } - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } - - val validationResult = - MinValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) - - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } - - @Test - fun `should return today's date when expression evaluates to today`() { - val today = LocalDate.now().toString() - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - }, - ) - }, - ) - }, - ) - assertThat((MinValueValidator.getMinValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(today) - } - - @Test - fun `should return minValue date`() { - val dateType = DateType(SimpleDateFormat("yyyy-MM-dd").parse("2021-06-01")) - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue(dateType) - }, - ) - }, - ) + val validationResult = MinValueValidator.validate(questionnaireItem, answer, context) - assertThat((MinValueValidator.getMinValue(questionItem.first()) as? DateType)?.value) - .isEqualTo(dateType.value) + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage).isEqualTo("Minimum value allowed is:$threeDaysAgo") } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt index dbfff75c12..c404430fc6 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt @@ -73,7 +73,11 @@ class QuestionnaireResponseItemValidatorTest { ) val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) assertThat(validationResult).isEqualTo(Valid) } @@ -111,7 +115,11 @@ class QuestionnaireResponseItemValidatorTest { ) val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid @@ -129,7 +137,11 @@ class QuestionnaireResponseItemValidatorTest { val answers = listOf() val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt index feaa9f63ca..9bfd1b9ad7 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt @@ -175,7 +175,11 @@ class RegexValidatorTest { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = - RegexValidator.validate(testComponent.requirement, testComponent.answer, context) + RegexValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -186,7 +190,11 @@ class RegexValidatorTest { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = - RegexValidator.validate(testComponent.requirement, testComponent.answer, context) + RegexValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage)